From 717bfb25fa33380df054738db730dd10e79afef4 Mon Sep 17 00:00:00 2001 From: Sophon Relay Date: Wed, 15 Apr 2026 16:59:02 -0500 Subject: [PATCH 01/44] Prepare crate release metadata Branch: main Changes Summary: Cargo.toml | 6 ++++++ LICENSE | 21 +++++++++++++++++++++ README.md | 51 +++++++++++++++++++++++++++++++++++++++++++++------ src/main.rs | 2 +- 4 files changed, 73 insertions(+), 7 deletions(-) Detailed Diffs (truncated to 200 lines): diff --git a/Cargo.toml b/Cargo.toml index 27d356b..dbedfb0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,12 @@ name = "sophon-cli" version = "0.1.0" edition = "2024" +description = "Provider-agnostic search CLI for Brave Search and Exa" +license = "MIT" +repository = "https://github.com/larock22/sophon" +readme = "README.md" +keywords = ["search", "cli", "brave", "exa"] +categories = ["command-line-utilities"] [dependencies] tokio = { version = "1", features = ["full"] } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..098c48b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sophon Relay + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index a0604c8..660867f 100644 --- a/README.md +++ b/README.md @@ -4,24 +4,63 @@ ![sophon](docs/sophon.png) -A provider-agnostic Rust CLI that queries the Brave Search API and prints normalized text results. +A provider-agnostic Rust CLI that queries Brave Search or Exa and prints normalized text results. -## Quick start +## Install ```bash -# Set your API key -echo "BRAVE_API_KEY=your_key_here" > .env +cargo install sophon-cli +``` -# Run a search +## Quick start + +```bash +# Run locally from the repo cargo run -- "rust programming" -# About +# Choose a provider explicitly +cargo run -- "rust programming" --provider brave +cargo run -- "rust programming" --provider exa + +# Show package info cargo run -- --about # Run all checks just check ``` +## Configuration + +Set the API key for the provider you want to use: + +```bash +# Brave +echo "BRAVE_API_KEY=your_key_here" > .env + +# Exa +echo "EXA_API_KEY=your_key_here" > .env +``` + +You can also export the variables directly in your shell instead of using `.env`. + +## Example usage + +```bash +# Web search with Brave +sophon-cli "rust programming" --provider brave + +# News search with Brave +sophon-cli "open source ai" --provider brave --search-type news --limit 3 + +# Exa search +sophon-cli "vector database benchmarks" --provider exa --limit 5 +``` + +## Supported providers + +- `brave` for web, news, images, and video search +- `exa` for Exa search results mapped into the shared domain model + ## Docs See the [architecture docs](docs/architecture.md) for the typed input-to-output flow and layer boundaries. diff --git a/src/main.rs b/src/main.rs index 88f0577..6ac73aa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,7 +29,7 @@ async fn main() { println!("across vast distances. This tiny CLI delegates its heavy lifting to"); println!("distant search APIs the same way."); println!(); - println!("Currently supports Brave Search (web, news, images, video)."); + println!("Currently supports Brave Search (web, news, images, video) and Exa."); return; } --- Cargo.toml | 6 ++++++ LICENSE | 21 +++++++++++++++++++++ README.md | 51 +++++++++++++++++++++++++++++++++++++++++++++------ src/main.rs | 2 +- 4 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 LICENSE diff --git a/Cargo.toml b/Cargo.toml index 27d356b..dbedfb0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,12 @@ name = "sophon-cli" version = "0.1.0" edition = "2024" +description = "Provider-agnostic search CLI for Brave Search and Exa" +license = "MIT" +repository = "https://github.com/larock22/sophon" +readme = "README.md" +keywords = ["search", "cli", "brave", "exa"] +categories = ["command-line-utilities"] [dependencies] tokio = { version = "1", features = ["full"] } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..098c48b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sophon Relay + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index a0604c8..660867f 100644 --- a/README.md +++ b/README.md @@ -4,24 +4,63 @@ ![sophon](docs/sophon.png) -A provider-agnostic Rust CLI that queries the Brave Search API and prints normalized text results. +A provider-agnostic Rust CLI that queries Brave Search or Exa and prints normalized text results. -## Quick start +## Install ```bash -# Set your API key -echo "BRAVE_API_KEY=your_key_here" > .env +cargo install sophon-cli +``` -# Run a search +## Quick start + +```bash +# Run locally from the repo cargo run -- "rust programming" -# About +# Choose a provider explicitly +cargo run -- "rust programming" --provider brave +cargo run -- "rust programming" --provider exa + +# Show package info cargo run -- --about # Run all checks just check ``` +## Configuration + +Set the API key for the provider you want to use: + +```bash +# Brave +echo "BRAVE_API_KEY=your_key_here" > .env + +# Exa +echo "EXA_API_KEY=your_key_here" > .env +``` + +You can also export the variables directly in your shell instead of using `.env`. + +## Example usage + +```bash +# Web search with Brave +sophon-cli "rust programming" --provider brave + +# News search with Brave +sophon-cli "open source ai" --provider brave --search-type news --limit 3 + +# Exa search +sophon-cli "vector database benchmarks" --provider exa --limit 5 +``` + +## Supported providers + +- `brave` for web, news, images, and video search +- `exa` for Exa search results mapped into the shared domain model + ## Docs See the [architecture docs](docs/architecture.md) for the typed input-to-output flow and layer boundaries. diff --git a/src/main.rs b/src/main.rs index 88f0577..6ac73aa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,7 +29,7 @@ async fn main() { println!("across vast distances. This tiny CLI delegates its heavy lifting to"); println!("distant search APIs the same way."); println!(); - println!("Currently supports Brave Search (web, news, images, video)."); + println!("Currently supports Brave Search (web, news, images, video) and Exa."); return; } From 27ed9e87f40d9707a73a3786c52ddd892656b23c Mon Sep 17 00:00:00 2001 From: Sophon Relay Date: Wed, 15 Apr 2026 17:06:20 -0500 Subject: [PATCH 02/44] Update crate repository metadata Branch: main Changes Summary: Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) Detailed Diffs (truncated to 200 lines): diff --git a/Cargo.toml b/Cargo.toml index dbedfb0..0b28afe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" description = "Provider-agnostic search CLI for Brave Search and Exa" license = "MIT" -repository = "https://github.com/larock22/sophon" +repository = "https://github.com/alchemiststudiosDOTai/sophon" readme = "README.md" keywords = ["search", "cli", "brave", "exa"] categories = ["command-line-utilities"] --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index dbedfb0..0b28afe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" description = "Provider-agnostic search CLI for Brave Search and Exa" license = "MIT" -repository = "https://github.com/larock22/sophon" +repository = "https://github.com/alchemiststudiosDOTai/sophon" readme = "README.md" keywords = ["search", "cli", "brave", "exa"] categories = ["command-line-utilities"] From 6bf047301676e739e5678d45726d0913f9ff9a59 Mon Sep 17 00:00:00 2001 From: larock22 Date: Wed, 22 Apr 2026 23:44:36 -0500 Subject: [PATCH 03/44] Enhance Exa provider with optional fields and improved response handling - Updated `ExaContentsRequest` to include optional `text`, `highlights`, and `summary` fields. - Introduced `ExaHighlightsRequest` and `ExaSummaryRequest` structs for better request structuring. - Modified `ExaResult` to include a `highlights` vector and adjusted deserialization tests accordingly. - Updated the `ExaProvider` to utilize new request structures and handle highlights and summaries in responses. - Enhanced mapping functions to prioritize summaries over highlights and ensure proper snippet generation for CLI output. --- src/cli/output.rs | 6 +- src/providers/exa/client.rs | 43 +++++++++-- src/providers/exa/dto.rs | 53 ++++++++++++- src/providers/exa/mapper.rs | 144 ++++++++++++++++++++++++++++++++++-- 4 files changed, 232 insertions(+), 14 deletions(-) diff --git a/src/cli/output.rs b/src/cli/output.rs index 80b9a90..eeb2fdc 100644 --- a/src/cli/output.rs +++ b/src/cli/output.rs @@ -22,6 +22,9 @@ pub fn render_text(response: &SearchResponse) -> String { if let Some(s) = &r.source { lines.push(format!(" Source: {}", s)); } + if let Some(s) = &r.snippet { + lines.push(format!(" {}", s)); + } } SearchResult::Image(r) => { lines.push(format!("{}. [IMAGE] {}", i + 1, r.title)); @@ -57,7 +60,7 @@ mod tests { SearchResult::News(NewsResult { title: "Rust News".to_string(), url: "https://example.com/news".to_string(), - snippet: None, + snippet: Some("Breaking update".to_string()), source: Some("Example".to_string()), published_at: None, }), @@ -84,6 +87,7 @@ mod tests { assert!(text.contains("Rust Lang")); assert!(text.contains("https://rust-lang.org")); assert!(text.contains("[NEWS] Rust News")); + assert!(text.contains("Breaking update")); assert!(text.contains("[IMAGE] Rust Logo")); assert!(text.contains("[VIDEO] Rust Tutorial")); } diff --git a/src/providers/exa/client.rs b/src/providers/exa/client.rs index eea9ebc..f93ee19 100644 --- a/src/providers/exa/client.rs +++ b/src/providers/exa/client.rs @@ -4,12 +4,18 @@ use crate::domain::query::SearchQuery; use crate::domain::result::SearchResponse; use crate::domain::types::{SafeSearch, SearchType, TimeRange}; use crate::providers::exa::config::ExaConfig; -use crate::providers::exa::dto::{ExaContentsRequest, ExaSearchRequest, ExaSearchResponse}; +use crate::providers::exa::dto::{ + ExaContentsRequest, ExaHighlightsRequest, ExaSearchRequest, ExaSearchResponse, + ExaSummaryRequest, +}; use crate::providers::exa::mapper::{map_news_response, map_web_response}; use crate::transport::http::HttpClient; use async_trait::async_trait; use chrono::{Duration, Utc}; +/// Budget for Exa per-result highlights (API); mapper applies a shorter CLI-facing cap. +const EXA_HIGHLIGHTS_MAX_CHARACTERS: u32 = 1200; + pub struct ExaProvider { client: C, config: ExaConfig, @@ -67,7 +73,16 @@ impl ExaProvider { Some(SafeSearch::Moderate | SafeSearch::Strict) => Some(true), None => None, }, - contents: ExaContentsRequest { text: true }, + contents: ExaContentsRequest { + text: None, + highlights: Some(ExaHighlightsRequest { + max_characters: EXA_HIGHLIGHTS_MAX_CHARACTERS, + query: Some(query.text.clone()), + }), + summary: Some(ExaSummaryRequest { + query: query.text.clone(), + }), + }, }) } } @@ -181,10 +196,28 @@ mod tests { assert_eq!(body.get("numResults"), Some(&Value::from(3))); assert_eq!(body.get("moderation"), Some(&Value::Bool(true))); assert_eq!(body.get("type"), Some(&Value::String("auto".to_string()))); + let contents = body.get("contents").unwrap(); + assert!(contents.get("text").is_none()); + assert_eq!( + contents + .get("highlights") + .and_then(|h| h.get("maxCharacters")) + .and_then(Value::as_u64), + Some(u64::from(super::EXA_HIGHLIGHTS_MAX_CHARACTERS)) + ); + assert_eq!( + contents + .get("highlights") + .and_then(|h| h.get("query")) + .and_then(Value::as_str), + Some("ai news") + ); assert_eq!( - body.get("contents") - .and_then(|contents| contents.get("text")), - Some(&Value::Bool(true)) + contents + .get("summary") + .and_then(|s| s.get("query")) + .and_then(Value::as_str), + Some("ai news") ); let start = body diff --git a/src/providers/exa/dto.rs b/src/providers/exa/dto.rs index 1ae6818..355ce96 100644 --- a/src/providers/exa/dto.rs +++ b/src/providers/exa/dto.rs @@ -24,7 +24,27 @@ pub struct ExaSearchRequest { #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct ExaContentsRequest { - pub text: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub highlights: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ExaHighlightsRequest { + pub max_characters: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub query: Option, +} + +/// Request-time `contents.summary` object with query (see Exa search API). +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ExaSummaryRequest { + pub query: String, } #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] @@ -43,6 +63,8 @@ pub struct ExaResult { pub url: Option, pub published_date: Option, pub author: Option, + #[serde(default)] + pub highlights: Vec, pub text: Option, pub summary: Option, } @@ -77,9 +99,38 @@ mod tests { url: Some("https://example.com".to_string()), published_date: Some("2026-04-15T00:00:00.000Z".to_string()), author: None, + highlights: vec![], text: None, summary: Some("Example summary".to_string()), }] ); } + + #[test] + fn test_exa_search_response_deserializes_highlights_and_summary() { + let json = r#"{ + "requestId": "req_456", + "searchType": "auto", + "results": [ + { + "title": "Article", + "url": "https://example.com/a", + "highlights": ["First excerpt.", "Second excerpt."], + "summary": "One-line overview", + "text": "FULL PAGE MARKDOWN WOULD BE HERE" + } + ] + }"#; + + let response: ExaSearchResponse = serde_json::from_str(json).unwrap(); + + assert_eq!(response.results.len(), 1); + let r = &response.results[0]; + assert_eq!( + r.highlights, + vec!["First excerpt.".to_string(), "Second excerpt.".to_string()] + ); + assert_eq!(r.summary.as_deref(), Some("One-line overview")); + assert_eq!(r.text.as_deref(), Some("FULL PAGE MARKDOWN WOULD BE HERE")); + } } diff --git a/src/providers/exa/mapper.rs b/src/providers/exa/mapper.rs index f31c9fc..f0c470d 100644 --- a/src/providers/exa/mapper.rs +++ b/src/providers/exa/mapper.rs @@ -1,6 +1,11 @@ use crate::domain::result::{NewsResult, SearchResponse, SearchResult, WebResult}; use crate::providers::exa::dto::{ExaResult, ExaSearchResponse}; +/// Maximum characters for `snippet` shown in the CLI (after join / trim). +const SNIPPET_DISPLAY_MAX_CHARS: usize = 500; + +const HIGHLIGHT_JOIN: &str = " … "; + pub fn map_web_response(query: &str, dto: ExaSearchResponse) -> SearchResponse { SearchResponse { query: query.to_string(), @@ -47,7 +52,40 @@ pub fn map_news_response(query: &str, dto: ExaSearchResponse) -> SearchResponse } fn preferred_snippet(result: &ExaResult) -> Option { - result.summary.clone().or_else(|| result.text.clone()) + if let Some(s) = result + .summary + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + return Some(cap_snippet_chars(s, SNIPPET_DISPLAY_MAX_CHARS)); + } + + let joined = join_highlights(&result.highlights); + if joined.is_empty() { + return None; + } + + Some(cap_snippet_chars(&joined, SNIPPET_DISPLAY_MAX_CHARS)) +} + +fn join_highlights(highlights: &[String]) -> String { + highlights + .iter() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .collect::>() + .join(HIGHLIGHT_JOIN) +} + +fn cap_snippet_chars(s: &str, max_chars: usize) -> String { + let count = s.chars().count(); + if count <= max_chars { + return s.to_string(); + } + let mut out: String = s.chars().take(max_chars.saturating_sub(1)).collect(); + out.push('…'); + out } #[cfg(test)] @@ -56,18 +94,27 @@ mod tests { use crate::domain::result::SearchResult; use crate::providers::exa::dto::{ExaResult, ExaSearchResponse}; + fn sample_result() -> ExaResult { + ExaResult { + title: Some("Example headline".to_string()), + url: Some("https://example.com/news".to_string()), + published_date: Some("2026-04-15T00:00:00.000Z".to_string()), + author: Some("Example Reporter".to_string()), + highlights: vec![], + text: None, + summary: Some("Short summary".to_string()), + } + } + #[test] fn test_map_news_response_prefers_summary_and_preserves_author() { let dto = ExaSearchResponse { request_id: Some("req_123".to_string()), search_type: Some("auto".to_string()), results: vec![ExaResult { - title: Some("Example headline".to_string()), - url: Some("https://example.com/news".to_string()), - published_date: Some("2026-04-15T00:00:00.000Z".to_string()), - author: Some("Example Reporter".to_string()), - text: Some("Longer body text".to_string()), - summary: Some("Short summary".to_string()), + highlights: vec!["Longer body text".to_string()], + text: Some("IGNORED FULL TEXT".to_string()), + ..sample_result() }], }; @@ -92,4 +139,87 @@ mod tests { other => panic!("expected news result, got {other:?}"), } } + + #[test] + fn test_map_prefers_summary_over_highlights() { + let dto = ExaSearchResponse { + request_id: None, + search_type: None, + results: vec![ExaResult { + summary: Some("Summary wins".to_string()), + highlights: vec!["Highlight A".to_string(), "Highlight B".to_string()], + text: Some("x".repeat(50_000)), + ..sample_result() + }], + }; + let response = map_news_response("q", dto); + match &response.results[0] { + SearchResult::News(r) => assert_eq!(r.snippet.as_deref(), Some("Summary wins")), + other => panic!("{other:?}"), + } + } + + #[test] + fn test_map_whitespace_summary_falls_through_to_highlights() { + let dto = ExaSearchResponse { + request_id: None, + search_type: None, + results: vec![ExaResult { + summary: Some(" \t ".to_string()), + highlights: vec!["Only this".to_string()], + text: Some("x".repeat(10_000)), + ..sample_result() + }], + }; + let response = map_news_response("q", dto); + match &response.results[0] { + SearchResult::News(r) => assert_eq!(r.snippet.as_deref(), Some("Only this")), + other => panic!("{other:?}"), + } + } + + #[test] + fn test_map_highlights_joined_and_capped() { + let a = "a".repeat(350); + let b = "b".repeat(350); + let dto = ExaSearchResponse { + request_id: None, + search_type: None, + results: vec![ExaResult { + summary: None, + highlights: vec![a, b], + text: Some("SHOULD NOT APPEAR".to_string()), + ..sample_result() + }], + }; + let response = map_news_response("q", dto); + match &response.results[0] { + SearchResult::News(r) => { + let s = r.snippet.as_ref().unwrap(); + assert!(!s.contains("SHOULD NOT APPEAR")); + assert!(s.chars().count() <= 500); + assert!(s.ends_with('…')); + } + other => panic!("{other:?}"), + } + } + + #[test] + fn test_map_no_concise_fields_returns_none_despite_huge_text() { + let dto = ExaSearchResponse { + request_id: None, + search_type: None, + results: vec![ExaResult { + summary: None, + highlights: vec![], + text: Some("x".repeat(100_000)), + ..sample_result() + }], + }; + let response = map_news_response("q", dto); + match &response.results[0] { + SearchResult::News(r) => assert!(r.snippet.is_none()), + other => panic!("{other:?}"), + } + } } From d76efa37edb12c1fa1ed9eb366733276b9d19845 Mon Sep 17 00:00:00 2001 From: larock22 Date: Wed, 22 Apr 2026 23:54:10 -0500 Subject: [PATCH 04/44] Add CHANGELOG for Exa concise snippets and CLI news snippets Document Unreleased changes: Exa contents request (highlights/summary), snippet mapping without full-text fallback, and CLI news snippet output. Made-with: Cursor --- CHANGELOG.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5d9ddc8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Changed + +- **Exa**: Default `/search` `contents` now requests **highlights** (with `maxCharacters` and the user query) and a **query-scoped summary** object instead of full-page **`text`**, so the API is not asked for article bodies for normal CLI usage. +- **Exa**: Normalized `snippet` is derived as **summary** (trimmed, capped) if non-empty, else **joined highlights** (separator ` … `, capped); **`text` is never used** as a snippet fallback, even when present in the response. + +### Fixed + +- **Exa**: Web results no longer dump full extracted page markdown into the terminal when `summary` is missing. + +### Added + +- **CLI**: News rows print **`snippet`** when present, matching web results and providers that populate `NewsResult.snippet`. From ba311ebde2f38d7ec82ec88df050ccd0c3444ede Mon Sep 17 00:00:00 2001 From: larock22 Date: Thu, 23 Apr 2026 23:32:44 -0500 Subject: [PATCH 05/44] Skip whitespace-only snippets in CLI text output --- src/cli/output.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/cli/output.rs b/src/cli/output.rs index eeb2fdc..652ba19 100644 --- a/src/cli/output.rs +++ b/src/cli/output.rs @@ -13,7 +13,10 @@ pub fn render_text(response: &SearchResponse) -> String { lines.push(format!("{}. [{}]", i + 1, r.title)); lines.push(format!(" URL: {}", r.url)); if let Some(s) = &r.snippet { - lines.push(format!(" {}", s)); + let t = s.trim(); + if !t.is_empty() { + lines.push(format!(" {}", t)); + } } } SearchResult::News(r) => { @@ -23,7 +26,10 @@ pub fn render_text(response: &SearchResponse) -> String { lines.push(format!(" Source: {}", s)); } if let Some(s) = &r.snippet { - lines.push(format!(" {}", s)); + let t = s.trim(); + if !t.is_empty() { + lines.push(format!(" {}", t)); + } } } SearchResult::Image(r) => { From 3280462ad9bd1bf688f2f95846f81e9767c8c651 Mon Sep 17 00:00:00 2001 From: larock22 Date: Sat, 25 Apr 2026 09:09:38 -0500 Subject: [PATCH 06/44] feat: add agent legibility infrastructure Add pre-commit hooks, GitHub templates, CODEOWNERS, skill definitions, AGENTS.md validation workflow, .env.example, and label documentation. - .pre-commit-config.yaml: general hygiene + just check gate - .github/workflows/validate-agents.yml: CI that verifies AGENTS.md paths and canonical commands - .github/ISSUE_TEMPLATE/: bug report and feature request templates - .github/pull_request_template.md: structured PR checklist - .github/CODEOWNERS: ownership fallback - .github/labels.yml: priority, type, and area label definitions - .factory/skills/sophon-cli/SKILL.md: agent skill with project context - .env.example: documents required API keys - Remove obsolete PRD.md reference from AGENTS.md and CI validation Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .env.example | 7 ++ .factory/skills/sophon-cli/SKILL.md | 45 ++++++++++++ .github/CODEOWNERS | 2 + .github/ISSUE_TEMPLATE/bug_report.md | 29 ++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 23 +++++++ .github/labels.yml | 44 ++++++++++++ .github/pull_request_template.md | 21 ++++++ .github/workflows/validate-agents.yml | 83 +++++++++++++++++++++++ .pre-commit-config.yaml | 17 +++++ AGENTS.md | 1 - 10 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 .factory/skills/sophon-cli/SKILL.md create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/labels.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/validate-agents.yml create mode 100644 .pre-commit-config.yaml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6795ba0 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Brave Search API key +# Get yours at: https://api.search.brave.com/ +BRAVE_API_KEY= + +# Exa API key +# Get yours at: https://exa.ai/ +EXA_API_KEY= diff --git a/.factory/skills/sophon-cli/SKILL.md b/.factory/skills/sophon-cli/SKILL.md new file mode 100644 index 0000000..56789d9 --- /dev/null +++ b/.factory/skills/sophon-cli/SKILL.md @@ -0,0 +1,45 @@ +--- +name: sophon-cli +description: Rust CLI for provider-agnostic web search using Brave Search or Exa APIs +--- + +# sophon-cli Agent Skill + +## Project +- Rust CLI binary (`sophon-cli`) with a provider-agnostic domain layer +- Brave Search and Exa adapters behind a trait boundary + +## Key Files +- `src/main.rs` — entrypoint wiring CLI args, app service, providers, and HTTP transport +- `src/domain/` — pure types and traits (no HTTP, no CLI parsing) +- `src/providers/brave/` — Brave-specific DTOs, mapper, config, and client +- `src/providers/exa/` — Exa-specific DTOs, mapper, config, and client +- `src/transport/` — `HttpClient` trait and `ReqwestHttpClient` adapter +- `src/app/` — `SearchService` orchestrator +- `src/cli/` — `clap` argument parsing and text rendering +- `tests/architecture_test.rs` — source-scan tests enforcing layer boundaries + +## Quality Gate +Run `just check` before committing. This runs: +1. `cargo fmt --check` +2. `cargo clippy -- -D warnings -W clippy::complexity -W clippy::cognitive_complexity` +3. `cargo test` +4. `mdbook build` + +## Architecture Boundaries +- `src/domain/` must NOT import `crate::providers`, `crate::transport`, `crate::cli`, `crate::app` +- `src/transport/` must NOT import `crate::providers`, `crate::cli`, `crate::app` +- `src/providers/` must NOT import `crate::cli`, `crate::app` +- `src/app/` must NOT import `crate::cli` +- Only `src/cli/` may import `render_text` + +## Environment Setup +Copy `.env.example` to `.env` and fill in the API key for the provider you want to use: +- `BRAVE_API_KEY` for Brave Search +- `EXA_API_KEY` for Exa + +## Common Tasks +- Add a new provider: create `src/providers//` with `config.rs`, `dto.rs`, `mapper.rs`, `client.rs`, then register in `src/providers/mod.rs` +- Add a domain type: place in `src/domain/` and update `src/domain/mod.rs` +- Modify CLI output: edit `src/cli/output.rs` and add unit tests there +- Update docs: edit files in `docs/` and run `mdbook build` to verify diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..6f39255 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Global fallback +* @alchemiststudiosDOTai diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..da96619 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug report +about: Report a bug or unexpected behavior +title: "[BUG] " +labels: ["bug", "triage"] +assignees: "" +--- + +## Describe the bug + + + +## To Reproduce + +1. Command or code snippet that triggers the issue +2. Environment (OS, Rust version, provider used) +3. Error output or unexpected behavior + +## Expected behavior + + + +## Screenshots / Logs + + + +## Additional context + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..95b0c87 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature request +about: Suggest an enhancement or new provider +title: "[FEATURE] " +labels: ["feature", "triage"] +assignees: "" +--- + +## Problem / Motivation + + + +## Proposed Solution + + + +## Alternatives Considered + + + +## Additional Context + + diff --git a/.github/labels.yml b/.github/labels.yml new file mode 100644 index 0000000..81be7e0 --- /dev/null +++ b/.github/labels.yml @@ -0,0 +1,44 @@ +# Label definitions for sophon-cli +# These labels are used for priority, type, and area classification. +- name: "P0-critical" + color: "b60205" + description: "Critical - blocks release or main workflow" +- name: "P1-high" + color: "d93f0b" + description: "High priority - significant impact" +- name: "P2-medium" + color: "fbca04" + description: "Medium priority - should be addressed soon" +- name: "P3-low" + color: "0e8a16" + description: "Low priority - nice to have" +- name: "bug" + color: "d73a4a" + description: "Something is broken" +- name: "feature" + color: "a2eeef" + description: "New capability or enhancement" +- name: "chore" + color: "c2e0c6" + description: "Maintenance, tooling, or cleanup" +- name: "docs" + color: "0075ca" + description: "Documentation related" +- name: "area/cli" + color: "0052cc" + description: "Command-line interface layer" +- name: "area/domain" + color: "0052cc" + description: "Provider-agnostic domain types" +- name: "area/providers" + color: "0052cc" + description: "Search provider adapters" +- name: "area/transport" + color: "0052cc" + description: "HTTP client and transport" +- name: "area/tests" + color: "0052cc" + description: "Test suite and harness" +- name: "triage" + color: "cccccc" + description: "Needs initial review and routing" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..1c7d846 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,21 @@ +## Description + + + +## Testing Done + + +- [ ] `just check` passes locally (fmt, clippy, tests, mdbook build) +- [ ] Unit tests added or updated for new behavior +- [ ] Architecture boundary tests still pass (`cargo test`) + +## Type of Change + +- [ ] Bug fix +- [ ] New feature +- [ ] Refactor / internal change +- [ ] Documentation update + +## Relevant Context + + diff --git a/.github/workflows/validate-agents.yml b/.github/workflows/validate-agents.yml new file mode 100644 index 0000000..aa903ca --- /dev/null +++ b/.github/workflows/validate-agents.yml @@ -0,0 +1,83 @@ +name: Validate AGENTS.md + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + validate-agents: + name: Validate AGENTS.md Consistency + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Verify AGENTS.md referenced paths exist + run: | + set -euo pipefail + echo "Checking file paths referenced in AGENTS.md..." + + # Extract file paths from AGENTS.md and check they exist + missing=0 + + # Check explicit paths in Repository Map + paths=( + "src/main.rs" + "src/domain/query.rs" + "src/domain/result.rs" + "src/domain/types.rs" + "src/domain/error.rs" + "src/domain/provider.rs" + "src/providers/brave" + "src/transport" + "src/app" + "src/cli/args.rs" + "src/cli/output.rs" + "tests/architecture_test.rs" + "docs" + "justfile" + "Cargo.toml" + "HARNESS.md" + ".artifacts/plan/2026-04-14_search-cli/PLAN.md" + ) + + for path in "${paths[@]}"; do + if [ ! -e "$path" ]; then + echo "MISSING: $path" + missing=$((missing + 1)) + else + echo "OK: $path" + fi + done + + if [ "$missing" -gt 0 ]; then + echo "Error: $missing referenced path(s) missing." + exit 1 + fi + + echo "All referenced paths exist." + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install just and mdbook + uses: taiki-e/install-action@v2 + with: + tool: just,mdbook + + - name: Verify canonical commands work + run: | + echo "Checking cargo fmt --check..." + cargo fmt --check + + echo "Checking cargo clippy..." + cargo clippy -- -D warnings -W clippy::complexity -W clippy::cognitive_complexity + + echo "Checking cargo test..." + cargo test + + echo "Checking mdbook build..." + mdbook build + + echo "All canonical commands validated." diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..c8ecba8 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + args: ["--maxkb=500"] + - repo: local + hooks: + - id: just-check + name: just check + entry: just check + language: system + pass_filenames: false + always_run: true diff --git a/AGENTS.md b/AGENTS.md index 5015f2b..359eafb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,6 @@ - Provider-agnostic domain layer with a Brave-specific adapter behind a trait boundary. ## Where To Start -- Design rules and boundaries: `PRD.md` - Implementation plan: `.artifacts/plan/2026-04-14_search-cli/PLAN.md` - Harness map (checks, tests, gaps): `HARNESS.md` From 7267aaa848c77ab7c3078947d4d91b33f64d58e1 Mon Sep 17 00:00:00 2001 From: larock22 Date: Sat, 25 Apr 2026 16:24:51 -0500 Subject: [PATCH 07/44] rollback: before executing plan provider-registry --- .../search-service-flow-visual.html | 289 +++++++++++++++ .../search-service-interface-options.html | 151 ++++++++ .../PLAN.md | 336 ++++++++++++++++++ .../tickets/INDEX.md | 20 ++ .../tickets/T001.md | 35 ++ .../tickets/T002.md | 36 ++ .../tickets/T003.md | 35 ++ .../tickets/T004.md | 32 ++ .../tickets/T005.md | 35 ++ .../tickets/T006.md | 32 ++ .../tickets/T007.md | 32 ++ .../tickets/T008.md | 74 ++++ 12 files changed, 1107 insertions(+) create mode 100644 .artifacts/interface-designs/search-service-flow-visual.html create mode 100644 .artifacts/interface-designs/search-service-interface-options.html create mode 100644 .artifacts/plan/2026-04-25_13-50-32_provider-registry/PLAN.md create mode 100644 .artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/INDEX.md create mode 100644 .artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T001.md create mode 100644 .artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T002.md create mode 100644 .artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T003.md create mode 100644 .artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T004.md create mode 100644 .artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T005.md create mode 100644 .artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T006.md create mode 100644 .artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T007.md create mode 100644 .artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T008.md diff --git a/.artifacts/interface-designs/search-service-flow-visual.html b/.artifacts/interface-designs/search-service-flow-visual.html new file mode 100644 index 0000000..865e7a3 --- /dev/null +++ b/.artifacts/interface-designs/search-service-flow-visual.html @@ -0,0 +1,289 @@ + + + + + + Search Service Wiring Visual + + + +
+

SearchService Wiring: What Actually Changes

+

+ This diagram is about the construction path around provider selection. + No search request is sent until service.search(query).await. The design question is: + how much wiring should main see? +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + Current: main is the wiring diagram + The branch does config loading, process exit, HTTP client creation, provider construction, boxing, and service construction. + + Option 1: Narrow Factory + Option 2: Registry Factory + Option 3: CLI App Runner + + + + + + + + + + + + main + args.provider + + + + match + Brave / Exa + exit on config error + + + + Config + from_env() + provider-specific + + + + HTTP + Reqwest + client wrapper + + + + Provider + BraveProvider + or ExaProvider + + + + Svc + boxed + + + Smell: the top-level command path must read every provider's construction recipe + + + + + + + + + main + parse args + handle exit + + + + build + service + one function + owns match + + + + Search + Service + + + + hidden inside factory + • BraveConfig::from_env() + • ExaConfig::from_env() + • ReqwestHttpClient::new() + • Provider constructors + Box + + + Best default + small interface, no new framework + + + + + + + + + main + selects name + handles exit + + + + Factory + registry + config source + http factory + + + + Search + Service + + + + replaceable parts + • ProviderRegistry + • ProviderConfigSource + • HttpClientFactory + • ProviderSelection::Named(...) + + + Best when growth is real + more extensible, more surface area + + + + + + + + + main + build command + render result + + + + CLI App + run(command) + build service + search + + + + Search + Response + + + + hidden in runner + • build SearchQuery/SearchCommand + • select provider + • construct service + • call service.search(...) + + + Best for command-oriented CLI + clean top level, less reusable service access + + + + CLI edge + + application/composition + + service/factory abstraction + + config/runtime + + smelly coupling + +
+ +
+
+

The likely move

+

Use Option 1 first: one composition helper moves the messy provider wiring out of main while keeping the model easy to read.

+
+
+

What not to do yet

+

Do not introduce the registry unless provider count, config sources, or test injection pressure actually grows.

+
+
+

The conceptual split

+

Construction happens before the request. Execution starts at service.search(query).await.

+
+
+
+ + diff --git a/.artifacts/interface-designs/search-service-interface-options.html b/.artifacts/interface-designs/search-service-interface-options.html new file mode 100644 index 0000000..3be3049 --- /dev/null +++ b/.artifacts/interface-designs/search-service-interface-options.html @@ -0,0 +1,151 @@ + + + + + + Search Service Interface Options + + +
+
+

Provider construction interface

+

Three Ways To Replace The Inline Match

+

The current smell is not that HTTP requests are being made in main. The smell is that main is manually wiring config, HTTP transport, concrete providers, boxing, and service construction for every provider.

+
+ +
+
+
+ 1 +
+

Narrow Factory

+

Smallest practical interface

+
+
+ +
+

Interface

+
pub fn build_search_service(
+    provider: CliProvider,
+) -> Result<SearchService, BuildServiceError>;
+
+ +
+

Caller

+
let service = build_search_service(args.provider)
+    .unwrap_or_else(exit_with_error);
+
+let response = service.search(query).await?;
+
+ +

Hides

+

Provider config loading, ReqwestHttpClient, concrete provider constructors, boxing, and SearchService::new.

+ +

Trade-off

+

Best fit for now. It removes duplication without inventing a provider system. The branch still exists, but it lives in one composition function instead of the CLI flow.

+
+ +
+
+ 2 +
+

Registry Factory

+

Most extensible shape

+
+
+ +
+

Interface

+
pub struct SearchServiceFactory { ... }
+
+impl SearchServiceFactory {
+    pub fn production() -> Self;
+    pub fn with_parts(
+        registry: ProviderRegistry,
+        config: Box<dyn ProviderConfigSource>,
+        http: Box<dyn HttpClientFactory>,
+    ) -> Self;
+    pub fn build(
+        &self,
+        provider: ProviderSelection,
+    ) -> Result<SearchService, FactoryError>;
+}
+
+ +
+

Caller

+
let provider = ProviderSelection::from(args.provider);
+
+let service = SearchServiceFactory::production()
+    .build(provider)
+    .unwrap_or_else(exit_with_error);
+
+ +

Hides

+

All provider construction plus where config comes from, how HTTP clients are created, and how provider names map to constructors.

+ +

Trade-off

+

Good if providers or config sources are about to multiply. Heavy for a two-provider CLI because it adds registry and factory abstractions before the code clearly needs them.

+
+ +
+
+ 3 +
+

CLI App Runner

+

Optimized for the command flow

+
+
+ +
+

Interface

+
pub struct SearchCommand {
+    pub query: SearchQuery,
+    pub provider: ProviderSelection,
+}
+
+pub struct SearchCliApp;
+
+impl SearchCliApp {
+    pub fn from_env() -> Self;
+    pub async fn run(
+        self,
+        command: SearchCommand,
+    ) -> Result<SearchResponse, SearchCliAppError>;
+}
+
+ +
+

Caller

+
let command = SearchCommand::from(args);
+
+match SearchCliApp::from_env().run(command).await {
+    Ok(response) => println!("{}", render_text(&response)),
+    Err(error) => exit_with_error(error),
+}
+
+ +

Hides

+

Provider construction and the call into SearchService::search. main becomes parse command, run app, render or exit.

+ +

Trade-off

+

Very clean for the CLI path, but it can blur composition with execution. It is less direct if other callers want a reusable SearchService.

+
+
+ +
+
+

Where They Diverge

+

The narrow factory keeps the existing architecture and just moves the noisy branch out of the reading path. The registry treats provider construction as a growing subsystem. The CLI app runner moves up a level and says the interface should be a command, not a service factory.

+

The key design choice is whether the caller wants a SearchService to keep using, or whether the caller just wants to execute one CLI search.

+
+ +
+

My Read

+

For this repo right now, I would start with Option 1: Narrow Factory. It is deep enough to hide the mess, small enough to understand instantly, and it leaves the current layer boundaries intact.

+

Option 2 is the future if providers/config sources grow. Option 3 is attractive if the binary should become an executable shell around a command object rather than a visible service orchestration flow.

+
+
+
+ + diff --git a/.artifacts/plan/2026-04-25_13-50-32_provider-registry/PLAN.md b/.artifacts/plan/2026-04-25_13-50-32_provider-registry/PLAN.md new file mode 100644 index 0000000..5de46ab --- /dev/null +++ b/.artifacts/plan/2026-04-25_13-50-32_provider-registry/PLAN.md @@ -0,0 +1,336 @@ +--- +title: "Provider registry implementation plan" +link: "provider-registry-plan" +type: implementation_plan +ontological_relations: + - relates_to: [[search-service-flow-visual]] + - relates_to: [[HARNESS]] +tags: [plan, provider-registry, rust, search-cli, coding] +uuid: "D81E9746-372F-41BA-9CAB-16A8CD55467D" +created_at: "2026-04-25T13:50:32Z" +parent_research: ".artifacts/interface-designs/search-service-flow-visual.html" +git_commit_at_plan: "39c796e" +--- + +## Goal + +Implement a compile-time built-in provider registry/factory that replaces the inline provider construction `match` in `src/main.rs`, scales cleanly as more providers are added, and only registers providers whose required env config is present. + +Out of scope: runtime plugin providers, external provider discovery, config files, redesigning the transport trait, changing provider request/mapper behavior, and adding new search providers. + +## Scope & Assumptions + +IN scope: +- Add a composition/bootstrap module that owns provider registration and `SearchService` construction. +- Add a provider-neutral `ProviderId` separate from `cli::args::CliProvider`. +- Add a registry that maps configured built-in providers to provider builder closures. +- Make production registry creation include Brave only when `BraveConfig::from_env()` succeeds and Exa only when `ExaConfig::from_env()` succeeds. +- Return a clear construction error when a selected provider is not registered because it is not configured. +- Keep `main` responsible for CLI parsing, query construction, output rendering, and process exit. +- Add focused tests for registry availability and `main` wiring boundaries where practical. + +OUT of scope: +- `Box` or transport object erasure. Current `HttpClient` has generic methods and is not object-safe. +- Shared erased config bags such as `ProviderConfig { api_key, base_url, extra }`. +- Lazy provider setup that defers missing-key failures until `search`. +- Runtime provider plugins or dynamic loading. +- Reworking `SearchProvider`, `SearchService`, or provider DTO mapping. + +Assumptions: +- `BraveConfig` and `ExaConfig` remain typed provider-owned config structs. +- `BraveConfig::from_env()` requires `BRAVE_API_KEY`; `ExaConfig::from_env()` requires `EXA_API_KEY`. +- `dotenvy::dotenv().ok()` remains in `main` before production registry creation. +- The architecture boundary tests may be extended, but existing `domain`, `transport`, `providers`, and `app` restrictions must continue to pass. +- The current untracked `.artifacts/interface-designs/` files are planning artifacts and should not affect source implementation. + +## Deliverables + +- `src/bootstrap/mod.rs` +- `src/bootstrap/provider_registry.rs` +- `src/main.rs` updates to call the registry/factory instead of inline provider construction +- Optional focused unit tests inside `src/bootstrap/provider_registry.rs` +- `tests/architecture_test.rs` updates if needed to document the new composition-layer boundary + +## Readiness + +Preconditions: +- Current source tree includes `src/providers/brave/*`, `src/providers/exa/*`, `src/app/search_service.rs`, `src/domain/provider.rs`, and `src/transport/http.rs`. +- The working tree may contain untracked planning artifacts under `.artifacts/interface-designs/`; execution should leave them intact. +- `just check` remains the canonical final verification command. + +What must exist before starting: +- Rust toolchain and dependencies already present. +- No source files need to be generated outside the paths listed in this plan. + +## Milestones + +- M1: Bootstrap registry API and error contract +- M2: Production provider registration from typed env config +- M3: Main wiring migration and architecture guardrails +- M4: Focused tests and final check path + +## Ticket Index + + + +| Task | Title | Ticket | +|---|---|---| +| T001 | Add bootstrap module skeleton and ProviderId | [tickets/T001.md](tickets/T001.md) | +| T002 | Define registry and construction error contract | [tickets/T002.md](tickets/T002.md) | +| T003 | Add production registry that only includes configured providers | [tickets/T003.md](tickets/T003.md) | +| T004 | Convert CliProvider to ProviderId at the binary edge | [tickets/T004.md](tickets/T004.md) | +| T005 | Replace inline provider construction in main | [tickets/T005.md](tickets/T005.md) | +| T006 | Extend architecture guardrails for the composition layer | [tickets/T006.md](tickets/T006.md) | +| T007 | Add focused registry tests | [tickets/T007.md](tickets/T007.md) | +| T008 | Run final local gate and update docs only if source references require it | [tickets/T008.md](tickets/T008.md) | + + + +## Work Breakdown (Tasks) + +### T001: Add bootstrap module skeleton and ProviderId + +**Summary**: Create the composition module and provider-neutral selection type without wiring any providers yet. + +**Owner**: backend + +**Estimate**: 45m + +**Dependencies**: + +**Target milestone**: M1 + +**Acceptance test**: `cargo check` passes with `mod bootstrap;` declared and no provider behavior changed. + +**Files/modules touched**: +- `src/main.rs` +- `src/bootstrap/mod.rs` +- `src/bootstrap/provider_registry.rs` + +**Steps**: +1. Add `mod bootstrap;` near the other module declarations in `src/main.rs`. +2. Create `src/bootstrap/mod.rs` with `pub mod provider_registry;`. +3. Create `src/bootstrap/provider_registry.rs`. +4. Define `#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ProviderId { Brave, Exa }`. +5. Implement `Display` for `ProviderId` with lowercase labels `brave` and `exa`. +6. Do not import `crate::cli` in `src/bootstrap/provider_registry.rs`. + +### T002: Define registry and construction error contract + +**Summary**: Add the provider registry type, provider builder alias, and construction errors used by production service creation. + +**Owner**: backend + +**Estimate**: 1h + +**Dependencies**: T001 + +**Target milestone**: M1 + +**Acceptance test**: Unit test constructs an empty registry and receives `ProviderUnavailable` when building `ProviderId::Brave`. + +**Files/modules touched**: +- `src/bootstrap/provider_registry.rs` + +**Steps**: +1. Import `std::collections::HashMap`, `crate::app::search_service::SearchService`, and `crate::domain::provider::SearchProvider`. +2. Define `pub type ProviderBuilder = Box Box + Send + Sync>;`. +3. Define `pub struct ProviderRegistry { builders: HashMap }`. +4. Add `pub fn empty() -> Self`. +5. Add `pub fn register(&mut self, id: ProviderId, builder: ProviderBuilder)`. +6. Add `pub fn available_providers(&self) -> Vec` that returns stable sorted order `[Brave, Exa]` when present. +7. Define `#[derive(Debug, thiserror::Error)] pub enum BuildSearchServiceError` with `ProviderUnavailable { provider: ProviderId, available: Vec }`. +8. Implement `pub fn build(&self, provider: ProviderId) -> Result` that creates `SearchService::new(builder())` when registered. +9. Add a unit test for empty registry unavailable behavior. + +### T003: Add production registry that only includes configured providers + +**Summary**: Implement env-backed production registration where missing provider keys mean the provider is omitted from the registry. + +**Owner**: backend + +**Estimate**: 1h + +**Dependencies**: T002 + +**Target milestone**: M2 + +**Acceptance test**: Unit test sets only `BRAVE_API_KEY` and confirms `production_from_env()` lists Brave but not Exa. + +**Files/modules touched**: +- `src/bootstrap/provider_registry.rs` + +**Steps**: +1. Import `BraveProvider`, `BraveConfig`, `ExaProvider`, `ExaConfig`, and `ReqwestHttpClient`. +2. Add `pub fn production_from_env() -> Self`. +3. In `production_from_env()`, call `BraveConfig::from_env()`. If it returns `Ok(config)`, register `ProviderId::Brave` with a closure that clones `config` and returns `Box::new(BraveProvider::new(ReqwestHttpClient::new(), config.clone()))`. +4. In `production_from_env()`, call `ExaConfig::from_env()`. If it returns `Ok(config)`, register `ProviderId::Exa` with a closure that clones `config` and returns `Box::new(ExaProvider::new(ReqwestHttpClient::new(), config.clone()))`. +5. If a config load returns `Err(std::env::VarError::NotPresent)`, do not register that provider. +6. If a config load returns `Err(std::env::VarError::NotUnicode(_))`, do not register that provider for now; surface the same `ProviderUnavailable` if selected. +7. Add tests that isolate env vars using a small test lock if needed because env is process-global. +8. Ensure tests restore any modified `BRAVE_API_KEY` and `EXA_API_KEY` values. + +### T004: Convert CliProvider to ProviderId at the binary edge + +**Summary**: Add explicit conversion from CLI provider enum to bootstrap provider ID without making the registry depend on CLI types. + +**Owner**: backend + +**Estimate**: 30m + +**Dependencies**: T001 + +**Target milestone**: M3 + +**Acceptance test**: `cargo test cli::args::tests::test_cli_provider_parses_exa_and_defaults_to_brave` still passes. + +**Files/modules touched**: +- `src/main.rs` + +**Steps**: +1. Import `crate::bootstrap::provider_registry::ProviderId` in `src/main.rs`. +2. Add `impl From for ProviderId` in `src/main.rs`. +3. Map `CliProvider::Brave` to `ProviderId::Brave`. +4. Map `CliProvider::Exa` to `ProviderId::Exa`. +5. Keep `src/bootstrap/provider_registry.rs` free of `crate::cli` imports. + +### T005: Replace inline provider construction in main + +**Summary**: Remove the duplicated provider construction `match` from `main` and delegate service construction to the production registry. + +**Owner**: backend + +**Estimate**: 45m + +**Dependencies**: T002,T003,T004 + +**Target milestone**: M3 + +**Acceptance test**: Running `cargo run -- "rust"` with no `BRAVE_API_KEY` prints a provider-unavailable/configuration message before any search request is attempted. + +**Files/modules touched**: +- `src/main.rs` + +**Steps**: +1. Remove direct imports of `SearchService`, `BraveProvider`, `BraveConfig`, `ExaProvider`, `ExaConfig`, and `ReqwestHttpClient` from `src/main.rs` if no longer used. +2. Import `ProviderRegistry` from `src/bootstrap/provider_registry.rs`. +3. Replace the inline `let service = match args.provider { ... };` block with: + - `let provider_id = ProviderId::from(args.provider);` + - `let registry = ProviderRegistry::production_from_env();` + - `let service = registry.build(provider_id).unwrap_or_else(|error| { eprintln!("{error}"); std::process::exit(1); });` +4. Keep the existing `service.search(query).await` block unchanged. +5. Ensure missing provider config fails during service construction, not during `service.search`. + +### T006: Extend architecture guardrails for the composition layer + +**Summary**: Document the new `bootstrap` layer in architecture tests so its intentionally broad dependencies do not weaken existing boundaries. + +**Owner**: backend + +**Estimate**: 45m + +**Dependencies**: T001,T005 + +**Target milestone**: M3 + +**Acceptance test**: `cargo test --test architecture_test` passes and includes a guard that `src/bootstrap` does not import `crate::cli`. + +**Files/modules touched**: +- `tests/architecture_test.rs` + +**Steps**: +1. Add a new architecture test named `test_bootstrap_does_not_import_cli`. +2. Use the existing `check_dir_for_forbidden_patterns` helper against `src/bootstrap`. +3. Forbid `use crate::cli::` in `src/bootstrap`. +4. Leave existing tests for `domain`, `transport`, `providers`, `app`, and `render_text` unchanged. +5. Do not forbid `bootstrap` from importing `app`, `providers`, `transport`, or `domain`; it is the composition layer. + +### T007: Add focused registry tests + +**Summary**: Add unit coverage for registration, available provider ordering, and successful service construction with fake providers. + +**Owner**: backend + +**Estimate**: 1h + +**Dependencies**: T002 + +**Target milestone**: M4 + +**Acceptance test**: `cargo test bootstrap::provider_registry` passes. + +**Files/modules touched**: +- `src/bootstrap/provider_registry.rs` + +**Steps**: +1. Add a `#[cfg(test)]` module in `src/bootstrap/provider_registry.rs`. +2. Define a local `MockProvider` implementing `SearchProvider`. +3. Add a test that registers `ProviderId::Brave`, verifies `available_providers()` returns `vec![ProviderId::Brave]`, and verifies `build(ProviderId::Brave)` succeeds. +4. Add a test that registers both providers and verifies stable ordering is `vec![ProviderId::Brave, ProviderId::Exa]`. +5. Keep env-dependent tests separated from pure registry tests. + +### T008: Run final local gate and update docs only if source references require it + +**Summary**: Run the repository check gate and only update developer docs if the new bootstrap layer makes existing maps inaccurate. + +**Owner**: backend + +**Estimate**: 45m + +**Dependencies**: T005,T006,T007 + +**Target milestone**: M4 + +**Acceptance test**: `just check` passes. + +**Files/modules touched**: +- `HARNESS.md` +- `docs/src/architecture.md` + +**Steps**: +1. Run `cargo fmt`. +2. Run `cargo test`. +3. Run `cargo clippy -- -D warnings -W clippy::complexity -W clippy::cognitive_complexity`. +4. Run `mdbook build`. +5. Run `just check` as the final umbrella gate. +6. If docs mention `src/main.rs` as directly wiring concrete providers, update only the affected lines in `docs/src/architecture.md`. +7. If `HARNESS.md` source index or architecture boundary section is now inaccurate because `src/bootstrap` exists, update only those lines. + +## Risks & Mitigations + +- Risk: `HttpClient` is not object-safe because it has generic methods. + Mitigation: Do not put `Box` in the registry. Keep concrete `ReqwestHttpClient` inside provider builders. +- Risk: Env-var tests are process-global and can be flaky under parallel test execution. + Mitigation: Prefer pure registry tests. If testing `production_from_env()`, guard env mutation with a test lock and restore variables. +- Risk: Omitting unconfigured providers may make the default `brave` provider fail differently than before. + Mitigation: Use a clear `ProviderUnavailable` message that names the selected provider and lists configured providers. +- Risk: A new composition layer can become a dumping ground. + Mitigation: Restrict it to provider registration and `SearchService` construction; keep query building and rendering in `main`/`cli`. +- Risk: Architecture tests could accidentally forbid the new composition layer from doing its job. + Mitigation: Add only a `bootstrap` no-CLI guard; do not forbid imports of `app`, `providers`, `transport`, or `domain`. + +## Test Strategy + +- T002 adds one unit test for unavailable provider behavior. +- T003 adds one env-backed availability test if it can be made deterministic. +- T004 relies on the existing CLI parsing test as the acceptance proof. +- T005 uses one manual CLI acceptance proof for missing-key behavior. +- T006 adds one architecture boundary test for `src/bootstrap`. +- T007 adds one focused registry unit test for successful construction and ordering. +- T008 runs the full check gate. + +## References + +- `src/main.rs:55` current inline provider construction branch +- `src/app/search_service.rs:6` `SearchService` owns `Box` +- `src/domain/provider.rs:19` `SearchProvider` trait object boundary +- `src/transport/http.rs:6` generic `HttpClient` methods are not object-safe +- `src/providers/brave/config.rs:8` Brave env config loading +- `src/providers/exa/config.rs:8` Exa env config loading +- `tests/architecture_test.rs:24` providers cannot import CLI or app +- `.artifacts/interface-designs/search-service-flow-visual.html` visual comparison of interface options + +## Final Gate + +- **Output summary**: plan dir path, milestone count, ticket count +- **Next step**: proceed to execute-phase with `.artifacts/plan/2026-04-25_13-50-32_provider-registry/PLAN.md` diff --git a/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/INDEX.md b/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/INDEX.md new file mode 100644 index 0000000..005fc53 --- /dev/null +++ b/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/INDEX.md @@ -0,0 +1,20 @@ +--- +title: "Ticket Index" +type: ticket_index +parent_plan: "../PLAN.md" +created_at: "2026-04-25T13:51:35Z" +tags: [ticket, plan] +--- + +# Ticket Index + +| Task | Title | Ticket | +|---|---|---| +| T001 | Add bootstrap module skeleton and ProviderId | [T001](./T001.md) | +| T002 | Define registry and construction error contract | [T002](./T002.md) | +| T003 | Add production registry that only includes configured providers | [T003](./T003.md) | +| T004 | Convert CliProvider to ProviderId at the binary edge | [T004](./T004.md) | +| T005 | Replace inline provider construction in main | [T005](./T005.md) | +| T006 | Extend architecture guardrails for the composition layer | [T006](./T006.md) | +| T007 | Add focused registry tests | [T007](./T007.md) | +| T008 | Run final local gate and update docs only if source references require it | [T008](./T008.md) | diff --git a/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T001.md b/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T001.md new file mode 100644 index 0000000..f1c2f87 --- /dev/null +++ b/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T001.md @@ -0,0 +1,35 @@ +--- +title: "T001: Add bootstrap module skeleton and ProviderId" +type: plan_ticket +task_id: "T001" +parent_plan: "../PLAN.md" +created_at: "2026-04-25T13:51:35Z" +tags: [ticket, plan] +--- + +# T001: Add bootstrap module skeleton and ProviderId + +**Summary**: Create the composition module and provider-neutral selection type without wiring any providers yet. + +**Owner**: backend + +**Estimate**: 45m + +**Dependencies**: + +**Target milestone**: M1 + +**Acceptance test**: `cargo check` passes with `mod bootstrap;` declared and no provider behavior changed. + +**Files/modules touched**: +- `src/main.rs` +- `src/bootstrap/mod.rs` +- `src/bootstrap/provider_registry.rs` + +**Steps**: +1. Add `mod bootstrap;` near the other module declarations in `src/main.rs`. +2. Create `src/bootstrap/mod.rs` with `pub mod provider_registry;`. +3. Create `src/bootstrap/provider_registry.rs`. +4. Define `#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ProviderId { Brave, Exa }`. +5. Implement `Display` for `ProviderId` with lowercase labels `brave` and `exa`. +6. Do not import `crate::cli` in `src/bootstrap/provider_registry.rs`. diff --git a/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T002.md b/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T002.md new file mode 100644 index 0000000..200b73e --- /dev/null +++ b/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T002.md @@ -0,0 +1,36 @@ +--- +title: "T002: Define registry and construction error contract" +type: plan_ticket +task_id: "T002" +parent_plan: "../PLAN.md" +created_at: "2026-04-25T13:51:35Z" +tags: [ticket, plan] +--- + +# T002: Define registry and construction error contract + +**Summary**: Add the provider registry type, provider builder alias, and construction errors used by production service creation. + +**Owner**: backend + +**Estimate**: 1h + +**Dependencies**: T001 + +**Target milestone**: M1 + +**Acceptance test**: Unit test constructs an empty registry and receives `ProviderUnavailable` when building `ProviderId::Brave`. + +**Files/modules touched**: +- `src/bootstrap/provider_registry.rs` + +**Steps**: +1. Import `std::collections::HashMap`, `crate::app::search_service::SearchService`, and `crate::domain::provider::SearchProvider`. +2. Define `pub type ProviderBuilder = Box Box + Send + Sync>;`. +3. Define `pub struct ProviderRegistry { builders: HashMap }`. +4. Add `pub fn empty() -> Self`. +5. Add `pub fn register(&mut self, id: ProviderId, builder: ProviderBuilder)`. +6. Add `pub fn available_providers(&self) -> Vec` that returns stable sorted order `[Brave, Exa]` when present. +7. Define `#[derive(Debug, thiserror::Error)] pub enum BuildSearchServiceError` with `ProviderUnavailable { provider: ProviderId, available: Vec }`. +8. Implement `pub fn build(&self, provider: ProviderId) -> Result` that creates `SearchService::new(builder())` when registered. +9. Add a unit test for empty registry unavailable behavior. diff --git a/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T003.md b/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T003.md new file mode 100644 index 0000000..c091371 --- /dev/null +++ b/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T003.md @@ -0,0 +1,35 @@ +--- +title: "T003: Add production registry that only includes configured providers" +type: plan_ticket +task_id: "T003" +parent_plan: "../PLAN.md" +created_at: "2026-04-25T13:51:35Z" +tags: [ticket, plan] +--- + +# T003: Add production registry that only includes configured providers + +**Summary**: Implement env-backed production registration where missing provider keys mean the provider is omitted from the registry. + +**Owner**: backend + +**Estimate**: 1h + +**Dependencies**: T002 + +**Target milestone**: M2 + +**Acceptance test**: Unit test sets only `BRAVE_API_KEY` and confirms `production_from_env()` lists Brave but not Exa. + +**Files/modules touched**: +- `src/bootstrap/provider_registry.rs` + +**Steps**: +1. Import `BraveProvider`, `BraveConfig`, `ExaProvider`, `ExaConfig`, and `ReqwestHttpClient`. +2. Add `pub fn production_from_env() -> Self`. +3. In `production_from_env()`, call `BraveConfig::from_env()`. If it returns `Ok(config)`, register `ProviderId::Brave` with a closure that clones `config` and returns `Box::new(BraveProvider::new(ReqwestHttpClient::new(), config.clone()))`. +4. In `production_from_env()`, call `ExaConfig::from_env()`. If it returns `Ok(config)`, register `ProviderId::Exa` with a closure that clones `config` and returns `Box::new(ExaProvider::new(ReqwestHttpClient::new(), config.clone()))`. +5. If a config load returns `Err(std::env::VarError::NotPresent)`, do not register that provider. +6. If a config load returns `Err(std::env::VarError::NotUnicode(_))`, do not register that provider for now; surface the same `ProviderUnavailable` if selected. +7. Add tests that isolate env vars using a small test lock if needed because env is process-global. +8. Ensure tests restore any modified `BRAVE_API_KEY` and `EXA_API_KEY` values. diff --git a/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T004.md b/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T004.md new file mode 100644 index 0000000..0ed175d --- /dev/null +++ b/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T004.md @@ -0,0 +1,32 @@ +--- +title: "T004: Convert CliProvider to ProviderId at the binary edge" +type: plan_ticket +task_id: "T004" +parent_plan: "../PLAN.md" +created_at: "2026-04-25T13:51:35Z" +tags: [ticket, plan] +--- + +# T004: Convert CliProvider to ProviderId at the binary edge + +**Summary**: Add explicit conversion from CLI provider enum to bootstrap provider ID without making the registry depend on CLI types. + +**Owner**: backend + +**Estimate**: 30m + +**Dependencies**: T001 + +**Target milestone**: M3 + +**Acceptance test**: `cargo test cli::args::tests::test_cli_provider_parses_exa_and_defaults_to_brave` still passes. + +**Files/modules touched**: +- `src/main.rs` + +**Steps**: +1. Import `crate::bootstrap::provider_registry::ProviderId` in `src/main.rs`. +2. Add `impl From for ProviderId` in `src/main.rs`. +3. Map `CliProvider::Brave` to `ProviderId::Brave`. +4. Map `CliProvider::Exa` to `ProviderId::Exa`. +5. Keep `src/bootstrap/provider_registry.rs` free of `crate::cli` imports. diff --git a/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T005.md b/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T005.md new file mode 100644 index 0000000..d576817 --- /dev/null +++ b/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T005.md @@ -0,0 +1,35 @@ +--- +title: "T005: Replace inline provider construction in main" +type: plan_ticket +task_id: "T005" +parent_plan: "../PLAN.md" +created_at: "2026-04-25T13:51:35Z" +tags: [ticket, plan] +--- + +# T005: Replace inline provider construction in main + +**Summary**: Remove the duplicated provider construction `match` from `main` and delegate service construction to the production registry. + +**Owner**: backend + +**Estimate**: 45m + +**Dependencies**: T002,T003,T004 + +**Target milestone**: M3 + +**Acceptance test**: Running `cargo run -- "rust"` with no `BRAVE_API_KEY` prints a provider-unavailable/configuration message before any search request is attempted. + +**Files/modules touched**: +- `src/main.rs` + +**Steps**: +1. Remove direct imports of `SearchService`, `BraveProvider`, `BraveConfig`, `ExaProvider`, `ExaConfig`, and `ReqwestHttpClient` from `src/main.rs` if no longer used. +2. Import `ProviderRegistry` from `src/bootstrap/provider_registry.rs`. +3. Replace the inline `let service = match args.provider { ... };` block with: + - `let provider_id = ProviderId::from(args.provider);` + - `let registry = ProviderRegistry::production_from_env();` + - `let service = registry.build(provider_id).unwrap_or_else(|error| { eprintln!("{error}"); std::process::exit(1); });` +4. Keep the existing `service.search(query).await` block unchanged. +5. Ensure missing provider config fails during service construction, not during `service.search`. diff --git a/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T006.md b/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T006.md new file mode 100644 index 0000000..deb0d1d --- /dev/null +++ b/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T006.md @@ -0,0 +1,32 @@ +--- +title: "T006: Extend architecture guardrails for the composition layer" +type: plan_ticket +task_id: "T006" +parent_plan: "../PLAN.md" +created_at: "2026-04-25T13:51:35Z" +tags: [ticket, plan] +--- + +# T006: Extend architecture guardrails for the composition layer + +**Summary**: Document the new `bootstrap` layer in architecture tests so its intentionally broad dependencies do not weaken existing boundaries. + +**Owner**: backend + +**Estimate**: 45m + +**Dependencies**: T001,T005 + +**Target milestone**: M3 + +**Acceptance test**: `cargo test --test architecture_test` passes and includes a guard that `src/bootstrap` does not import `crate::cli`. + +**Files/modules touched**: +- `tests/architecture_test.rs` + +**Steps**: +1. Add a new architecture test named `test_bootstrap_does_not_import_cli`. +2. Use the existing `check_dir_for_forbidden_patterns` helper against `src/bootstrap`. +3. Forbid `use crate::cli::` in `src/bootstrap`. +4. Leave existing tests for `domain`, `transport`, `providers`, `app`, and `render_text` unchanged. +5. Do not forbid `bootstrap` from importing `app`, `providers`, `transport`, or `domain`; it is the composition layer. diff --git a/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T007.md b/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T007.md new file mode 100644 index 0000000..319ed38 --- /dev/null +++ b/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T007.md @@ -0,0 +1,32 @@ +--- +title: "T007: Add focused registry tests" +type: plan_ticket +task_id: "T007" +parent_plan: "../PLAN.md" +created_at: "2026-04-25T13:51:35Z" +tags: [ticket, plan] +--- + +# T007: Add focused registry tests + +**Summary**: Add unit coverage for registration, available provider ordering, and successful service construction with fake providers. + +**Owner**: backend + +**Estimate**: 1h + +**Dependencies**: T002 + +**Target milestone**: M4 + +**Acceptance test**: `cargo test bootstrap::provider_registry` passes. + +**Files/modules touched**: +- `src/bootstrap/provider_registry.rs` + +**Steps**: +1. Add a `#[cfg(test)]` module in `src/bootstrap/provider_registry.rs`. +2. Define a local `MockProvider` implementing `SearchProvider`. +3. Add a test that registers `ProviderId::Brave`, verifies `available_providers()` returns `vec![ProviderId::Brave]`, and verifies `build(ProviderId::Brave)` succeeds. +4. Add a test that registers both providers and verifies stable ordering is `vec![ProviderId::Brave, ProviderId::Exa]`. +5. Keep env-dependent tests separated from pure registry tests. diff --git a/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T008.md b/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T008.md new file mode 100644 index 0000000..77afae9 --- /dev/null +++ b/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T008.md @@ -0,0 +1,74 @@ +--- +title: "T008: Run final local gate and update docs only if source references require it" +type: plan_ticket +task_id: "T008" +parent_plan: "../PLAN.md" +created_at: "2026-04-25T13:51:35Z" +tags: [ticket, plan] +--- + +# T008: Run final local gate and update docs only if source references require it + +**Summary**: Run the repository check gate and only update developer docs if the new bootstrap layer makes existing maps inaccurate. + +**Owner**: backend + +**Estimate**: 45m + +**Dependencies**: T005,T006,T007 + +**Target milestone**: M4 + +**Acceptance test**: `just check` passes. + +**Files/modules touched**: +- `HARNESS.md` +- `docs/src/architecture.md` + +**Steps**: +1. Run `cargo fmt`. +2. Run `cargo test`. +3. Run `cargo clippy -- -D warnings -W clippy::complexity -W clippy::cognitive_complexity`. +4. Run `mdbook build`. +5. Run `just check` as the final umbrella gate. +6. If docs mention `src/main.rs` as directly wiring concrete providers, update only the affected lines in `docs/src/architecture.md`. +7. If `HARNESS.md` source index or architecture boundary section is now inaccurate because `src/bootstrap` exists, update only those lines. + +## Risks & Mitigations + +- Risk: `HttpClient` is not object-safe because it has generic methods. + Mitigation: Do not put `Box` in the registry. Keep concrete `ReqwestHttpClient` inside provider builders. +- Risk: Env-var tests are process-global and can be flaky under parallel test execution. + Mitigation: Prefer pure registry tests. If testing `production_from_env()`, guard env mutation with a test lock and restore variables. +- Risk: Omitting unconfigured providers may make the default `brave` provider fail differently than before. + Mitigation: Use a clear `ProviderUnavailable` message that names the selected provider and lists configured providers. +- Risk: A new composition layer can become a dumping ground. + Mitigation: Restrict it to provider registration and `SearchService` construction; keep query building and rendering in `main`/`cli`. +- Risk: Architecture tests could accidentally forbid the new composition layer from doing its job. + Mitigation: Add only a `bootstrap` no-CLI guard; do not forbid imports of `app`, `providers`, `transport`, or `domain`. + +## Test Strategy + +- T002 adds one unit test for unavailable provider behavior. +- T003 adds one env-backed availability test if it can be made deterministic. +- T004 relies on the existing CLI parsing test as the acceptance proof. +- T005 uses one manual CLI acceptance proof for missing-key behavior. +- T006 adds one architecture boundary test for `src/bootstrap`. +- T007 adds one focused registry unit test for successful construction and ordering. +- T008 runs the full check gate. + +## References + +- `src/main.rs:55` current inline provider construction branch +- `src/app/search_service.rs:6` `SearchService` owns `Box` +- `src/domain/provider.rs:19` `SearchProvider` trait object boundary +- `src/transport/http.rs:6` generic `HttpClient` methods are not object-safe +- `src/providers/brave/config.rs:8` Brave env config loading +- `src/providers/exa/config.rs:8` Exa env config loading +- `tests/architecture_test.rs:24` providers cannot import CLI or app +- `.artifacts/interface-designs/search-service-flow-visual.html` visual comparison of interface options + +## Final Gate + +- **Output summary**: plan dir path, milestone count, ticket count +- **Next step**: proceed to execute-phase with `.artifacts/plan/2026-04-25_13-50-32_provider-registry/PLAN.md` From 9e0087d94ce940e56b839a55ede76969c9a6043c Mon Sep 17 00:00:00 2001 From: larock22 Date: Sat, 25 Apr 2026 16:25:40 -0500 Subject: [PATCH 08/44] T001: add bootstrap provider id Add the bootstrap module skeleton and provider-neutral ProviderId used by the upcoming registry. Refs: .artifacts/plan/2026-04-25_13-50-32_provider-registry/PLAN.md --- .../2026-04-25_21-24-49_provider-registry.md | 104 ++++++++++++++++++ src/bootstrap/mod.rs | 1 + src/bootstrap/provider_registry.rs | 16 +++ src/main.rs | 1 + 4 files changed, 122 insertions(+) create mode 100644 .artifacts/execute/2026-04-25_21-24-49_provider-registry.md create mode 100644 src/bootstrap/mod.rs create mode 100644 src/bootstrap/provider_registry.rs diff --git a/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md b/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md new file mode 100644 index 0000000..befe9e2 --- /dev/null +++ b/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md @@ -0,0 +1,104 @@ +--- +title: "Provider registry execution log" +link: "provider-registry-execute" +type: debug_history +ontological_relations: + - relates_to: [[provider-registry-plan]] +tags: [execute, provider-registry] +uuid: "F4655A49-209E-4388-B038-F01351808759" +created_at: "2026-04-25T21:24:49Z" +owner: "tuna" +plan_path: ".artifacts/plan/2026-04-25_13-50-32_provider-registry/PLAN.md" +start_commit: "39c796e" +rollback_commit: "7267aaa" +env: {target: "local", notes: "Rust CLI local implementation"} +--- + +## Pre-Flight Checks +- Branch: redesign-main-search-interface +- Rollback: 7267aaa +- DoR: satisfied +- Ready: yes +- Access/secrets: not required for implementation; live API calls require provider env vars +- Fixtures/data: ready + +## Task Execution + +### T001 - Add bootstrap module skeleton and ProviderId +- Status: completed +- Commit: pending +- Files: src/main.rs; src/bootstrap/mod.rs; src/bootstrap/provider_registry.rs +- Commands: `cargo check` -> pass with expected temporary dead-code warning for ProviderId +- Tests: cargo check pass +- Notes: Added composition module skeleton only; production provider behavior unchanged. + +### T002 - Define registry and construction error contract +- Status: pending +- Commit: pending +- Files: pending +- Commands: pending +- Tests: pending +- Notes: pending + +### T003 - Add production registry that only includes configured providers +- Status: pending +- Commit: pending +- Files: pending +- Commands: pending +- Tests: pending +- Notes: pending + +### T004 - Convert CliProvider to ProviderId at the binary edge +- Status: pending +- Commit: pending +- Files: pending +- Commands: pending +- Tests: pending +- Notes: pending + +### T005 - Replace inline provider construction in main +- Status: pending +- Commit: pending +- Files: pending +- Commands: pending +- Tests: pending +- Notes: pending + +### T006 - Extend architecture guardrails for the composition layer +- Status: pending +- Commit: pending +- Files: pending +- Commands: pending +- Tests: pending +- Notes: pending + +### T007 - Add focused registry tests +- Status: pending +- Commit: pending +- Files: pending +- Commands: pending +- Tests: pending +- Notes: pending + +### T008 - Run final local gate and update docs only if source references require it +- Status: pending +- Commit: pending +- Files: pending +- Commands: pending +- Tests: pending +- Notes: pending + +## Gate Results +- Tests: pending +- Type checks: pending +- Linters: pending +- Docs build: pending +- Umbrella gate: pending + +## Issues & Resolutions +- None yet + +## Success Criteria +- [ ] All planned gates passed +- [ ] Execution log saved +- [ ] Source references updated only where required diff --git a/src/bootstrap/mod.rs b/src/bootstrap/mod.rs new file mode 100644 index 0000000..0fe533b --- /dev/null +++ b/src/bootstrap/mod.rs @@ -0,0 +1 @@ +pub mod provider_registry; diff --git a/src/bootstrap/provider_registry.rs b/src/bootstrap/provider_registry.rs new file mode 100644 index 0000000..58e5b90 --- /dev/null +++ b/src/bootstrap/provider_registry.rs @@ -0,0 +1,16 @@ +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ProviderId { + Brave, + Exa, +} + +impl fmt::Display for ProviderId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ProviderId::Brave => formatter.write_str("brave"), + ProviderId::Exa => formatter.write_str("exa"), + } + } +} diff --git a/src/main.rs b/src/main.rs index 6ac73aa..249359c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod app; +mod bootstrap; mod cli; mod domain; mod providers; From 8886c964a935d5161dc022b6e02532cc0ec633c2 Mon Sep 17 00:00:00 2001 From: larock22 Date: Sat, 25 Apr 2026 16:26:29 -0500 Subject: [PATCH 09/44] T002: add provider registry contract Define provider builders, registry lookup, stable availability ordering, and the provider-unavailable error used during service construction. Refs: .artifacts/plan/2026-04-25_13-50-32_provider-registry/PLAN.md --- .../2026-04-25_21-24-49_provider-registry.md | 12 ++-- src/bootstrap/provider_registry.rs | 71 +++++++++++++++++++ 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md b/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md index befe9e2..d2cbac9 100644 --- a/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md +++ b/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md @@ -26,19 +26,19 @@ env: {target: "local", notes: "Rust CLI local implementation"} ### T001 - Add bootstrap module skeleton and ProviderId - Status: completed -- Commit: pending +- Commit: 9e0087d - Files: src/main.rs; src/bootstrap/mod.rs; src/bootstrap/provider_registry.rs - Commands: `cargo check` -> pass with expected temporary dead-code warning for ProviderId - Tests: cargo check pass - Notes: Added composition module skeleton only; production provider behavior unchanged. ### T002 - Define registry and construction error contract -- Status: pending +- Status: completed - Commit: pending -- Files: pending -- Commands: pending -- Tests: pending -- Notes: pending +- Files: src/bootstrap/provider_registry.rs +- Commands: `cargo test bootstrap::provider_registry::tests::empty_registry_reports_provider_unavailable` -> pass +- Tests: focused unavailable-provider unit test pass +- Notes: Added registry builder map, stable availability ordering, and ProviderUnavailable construction error. ### T003 - Add production registry that only includes configured providers - Status: pending diff --git a/src/bootstrap/provider_registry.rs b/src/bootstrap/provider_registry.rs index 58e5b90..c7d4183 100644 --- a/src/bootstrap/provider_registry.rs +++ b/src/bootstrap/provider_registry.rs @@ -1,5 +1,9 @@ +use std::collections::HashMap; use std::fmt; +use crate::app::search_service::SearchService; +use crate::domain::provider::SearchProvider; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ProviderId { Brave, @@ -14,3 +18,70 @@ impl fmt::Display for ProviderId { } } } + +pub type ProviderBuilder = Box Box + Send + Sync>; + +pub struct ProviderRegistry { + builders: HashMap, +} + +#[derive(Debug, thiserror::Error)] +pub enum BuildSearchServiceError { + #[error("provider `{provider}` is unavailable; configured providers: {available:?}")] + ProviderUnavailable { + provider: ProviderId, + available: Vec, + }, +} + +impl ProviderRegistry { + pub fn empty() -> Self { + Self { + builders: HashMap::new(), + } + } + + pub fn register(&mut self, id: ProviderId, builder: ProviderBuilder) { + self.builders.insert(id, builder); + } + + pub fn available_providers(&self) -> Vec { + [ProviderId::Brave, ProviderId::Exa] + .into_iter() + .filter(|id| self.builders.contains_key(id)) + .collect() + } + + pub fn build(&self, provider: ProviderId) -> Result { + let builder = + self.builders + .get(&provider) + .ok_or_else(|| BuildSearchServiceError::ProviderUnavailable { + provider, + available: self.available_providers(), + })?; + + Ok(SearchService::new(builder())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_registry_reports_provider_unavailable() { + let registry = ProviderRegistry::empty(); + + match registry.build(ProviderId::Brave) { + Err(BuildSearchServiceError::ProviderUnavailable { + provider, + available, + }) => { + assert_eq!(provider, ProviderId::Brave); + assert!(available.is_empty()); + } + Ok(_) => panic!("expected ProviderUnavailable error"), + } + } +} From d942b93720ffebecb58f6185b562cdcd58002b8d Mon Sep 17 00:00:00 2001 From: larock22 Date: Sat, 25 Apr 2026 16:27:17 -0500 Subject: [PATCH 10/44] T003: add production provider registry Register Brave and Exa providers from their typed environment configs, omitting unconfigured providers from the registry. Refs: .artifacts/plan/2026-04-25_13-50-32_provider-registry/PLAN.md --- .../2026-04-25_21-24-49_provider-registry.md | 12 ++-- src/bootstrap/provider_registry.rs | 72 +++++++++++++++++++ 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md b/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md index d2cbac9..70c3a89 100644 --- a/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md +++ b/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md @@ -34,19 +34,19 @@ env: {target: "local", notes: "Rust CLI local implementation"} ### T002 - Define registry and construction error contract - Status: completed -- Commit: pending +- Commit: 8886c96 - Files: src/bootstrap/provider_registry.rs - Commands: `cargo test bootstrap::provider_registry::tests::empty_registry_reports_provider_unavailable` -> pass - Tests: focused unavailable-provider unit test pass - Notes: Added registry builder map, stable availability ordering, and ProviderUnavailable construction error. ### T003 - Add production registry that only includes configured providers -- Status: pending +- Status: completed - Commit: pending -- Files: pending -- Commands: pending -- Tests: pending -- Notes: pending +- Files: src/bootstrap/provider_registry.rs +- Commands: `cargo test bootstrap::provider_registry` -> pass +- Tests: env-backed production availability test pass +- Notes: Registered Brave and Exa only when typed env config constructors succeed; missing or non-Unicode keys omit providers. ### T004 - Convert CliProvider to ProviderId at the binary edge - Status: pending diff --git a/src/bootstrap/provider_registry.rs b/src/bootstrap/provider_registry.rs index c7d4183..94bf6ed 100644 --- a/src/bootstrap/provider_registry.rs +++ b/src/bootstrap/provider_registry.rs @@ -3,6 +3,11 @@ use std::fmt; use crate::app::search_service::SearchService; use crate::domain::provider::SearchProvider; +use crate::providers::brave::client::BraveProvider; +use crate::providers::brave::config::BraveConfig; +use crate::providers::exa::client::ExaProvider; +use crate::providers::exa::config::ExaConfig; +use crate::transport::http::ReqwestHttpClient; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ProviderId { @@ -41,6 +46,36 @@ impl ProviderRegistry { } } + pub fn production_from_env() -> Self { + let mut registry = Self::empty(); + + match BraveConfig::from_env() { + Ok(config) => { + registry.register( + ProviderId::Brave, + Box::new(move || { + Box::new(BraveProvider::new(ReqwestHttpClient::new(), config.clone())) + }), + ); + } + Err(std::env::VarError::NotPresent | std::env::VarError::NotUnicode(_)) => {} + } + + match ExaConfig::from_env() { + Ok(config) => { + registry.register( + ProviderId::Exa, + Box::new(move || { + Box::new(ExaProvider::new(ReqwestHttpClient::new(), config.clone())) + }), + ); + } + Err(std::env::VarError::NotPresent | std::env::VarError::NotUnicode(_)) => {} + } + + registry + } + pub fn register(&mut self, id: ProviderId, builder: ProviderBuilder) { self.builders.insert(id, builder); } @@ -68,6 +103,24 @@ impl ProviderRegistry { #[cfg(test)] mod tests { use super::*; + use std::ffi::OsString; + use std::sync::{Mutex, OnceLock}; + + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + fn restore_env_var(name: &str, value: Option) { + match value { + Some(value) => unsafe { + std::env::set_var(name, value); + }, + None => unsafe { + std::env::remove_var(name); + }, + } + } #[test] fn empty_registry_reports_provider_unavailable() { @@ -84,4 +137,23 @@ mod tests { Ok(_) => panic!("expected ProviderUnavailable error"), } } + + #[test] + fn production_registry_only_includes_configured_providers() { + let _guard = env_lock().lock().unwrap(); + let original_brave = std::env::var_os("BRAVE_API_KEY"); + let original_exa = std::env::var_os("EXA_API_KEY"); + + unsafe { + std::env::set_var("BRAVE_API_KEY", "test-brave-key"); + std::env::remove_var("EXA_API_KEY"); + } + + let registry = ProviderRegistry::production_from_env(); + + restore_env_var("BRAVE_API_KEY", original_brave); + restore_env_var("EXA_API_KEY", original_exa); + + assert_eq!(registry.available_providers(), vec![ProviderId::Brave]); + } } From e441cb976bdf6a30a51d0e6dd6153849eb5612c4 Mon Sep 17 00:00:00 2001 From: larock22 Date: Sat, 25 Apr 2026 16:27:39 -0500 Subject: [PATCH 11/44] T004: convert cli provider at edge Map CliProvider into the provider-neutral ProviderId in main without making the bootstrap registry depend on CLI types. Refs: .artifacts/plan/2026-04-25_13-50-32_provider-registry/PLAN.md --- .../execute/2026-04-25_21-24-49_provider-registry.md | 12 ++++++------ src/main.rs | 10 ++++++++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md b/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md index 70c3a89..3524fdb 100644 --- a/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md +++ b/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md @@ -42,19 +42,19 @@ env: {target: "local", notes: "Rust CLI local implementation"} ### T003 - Add production registry that only includes configured providers - Status: completed -- Commit: pending +- Commit: d942b93 - Files: src/bootstrap/provider_registry.rs - Commands: `cargo test bootstrap::provider_registry` -> pass - Tests: env-backed production availability test pass - Notes: Registered Brave and Exa only when typed env config constructors succeed; missing or non-Unicode keys omit providers. ### T004 - Convert CliProvider to ProviderId at the binary edge -- Status: pending +- Status: completed - Commit: pending -- Files: pending -- Commands: pending -- Tests: pending -- Notes: pending +- Files: src/main.rs +- Commands: `cargo test cli::args::tests::test_cli_provider_parses_exa_and_defaults_to_brave` -> pass +- Tests: CLI provider parsing regression test pass +- Notes: Conversion lives in the binary edge; bootstrap remains CLI-independent. ### T005 - Replace inline provider construction in main - Status: pending diff --git a/src/main.rs b/src/main.rs index 249359c..730a2fb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod providers; mod transport; use app::search_service::SearchService; +use bootstrap::provider_registry::ProviderId; use clap::Parser; use cli::args::{CliArgs, CliProvider}; use cli::output::render_text; @@ -16,6 +17,15 @@ use providers::exa::client::ExaProvider; use providers::exa::config::ExaConfig; use transport::http::ReqwestHttpClient; +impl From for ProviderId { + fn from(provider: CliProvider) -> Self { + match provider { + CliProvider::Brave => ProviderId::Brave, + CliProvider::Exa => ProviderId::Exa, + } + } +} + #[tokio::main] async fn main() { dotenvy::dotenv().ok(); From 5129cf568dca1abfbc42430626162193cba485b6 Mon Sep 17 00:00:00 2001 From: larock22 Date: Sat, 25 Apr 2026 16:28:16 -0500 Subject: [PATCH 12/44] T005: build search service through registry Replace inline provider construction in main with ProviderRegistry::production_from_env and registry-based service construction. Refs: .artifacts/plan/2026-04-25_13-50-32_provider-registry/PLAN.md --- .../2026-04-25_21-24-49_provider-registry.md | 12 +++--- src/main.rs | 38 ++++--------------- 2 files changed, 13 insertions(+), 37 deletions(-) diff --git a/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md b/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md index 3524fdb..6430947 100644 --- a/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md +++ b/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md @@ -50,19 +50,19 @@ env: {target: "local", notes: "Rust CLI local implementation"} ### T004 - Convert CliProvider to ProviderId at the binary edge - Status: completed -- Commit: pending +- Commit: e441cb9 - Files: src/main.rs - Commands: `cargo test cli::args::tests::test_cli_provider_parses_exa_and_defaults_to_brave` -> pass - Tests: CLI provider parsing regression test pass - Notes: Conversion lives in the binary edge; bootstrap remains CLI-independent. ### T005 - Replace inline provider construction in main -- Status: pending +- Status: completed - Commit: pending -- Files: pending -- Commands: pending -- Tests: pending -- Notes: pending +- Files: src/main.rs +- Commands: `cargo check` -> pass; `env -u BRAVE_API_KEY -u EXA_API_KEY cargo run -- "rust"` -> exits 1 with `provider `brave` is unavailable; configured providers: []` +- Tests: missing-key CLI acceptance proof pass +- Notes: Main now builds the selected service through the production registry before search execution. ### T006 - Extend architecture guardrails for the composition layer - Status: pending diff --git a/src/main.rs b/src/main.rs index 730a2fb..29b6f10 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,17 +5,11 @@ mod domain; mod providers; mod transport; -use app::search_service::SearchService; -use bootstrap::provider_registry::ProviderId; +use bootstrap::provider_registry::{ProviderId, ProviderRegistry}; use clap::Parser; use cli::args::{CliArgs, CliProvider}; use cli::output::render_text; use domain::query::SearchQuery; -use providers::brave::client::BraveProvider; -use providers::brave::config::BraveConfig; -use providers::exa::client::ExaProvider; -use providers::exa::config::ExaConfig; -use transport::http::ReqwestHttpClient; impl From for ProviderId { fn from(provider: CliProvider) -> Self { @@ -63,30 +57,12 @@ async fn main() { time_range: None, }; - let service = match args.provider { - CliProvider::Brave => { - let config = match BraveConfig::from_env() { - Ok(config) => config, - Err(error) => { - eprintln!("Failed to load Brave config: {}", error); - std::process::exit(1); - } - }; - let provider = BraveProvider::new(ReqwestHttpClient::new(), config); - SearchService::new(Box::new(provider)) - } - CliProvider::Exa => { - let config = match ExaConfig::from_env() { - Ok(config) => config, - Err(error) => { - eprintln!("Failed to load Exa config: {}", error); - std::process::exit(1); - } - }; - let provider = ExaProvider::new(ReqwestHttpClient::new(), config); - SearchService::new(Box::new(provider)) - } - }; + let provider_id = ProviderId::from(args.provider); + let registry = ProviderRegistry::production_from_env(); + let service = registry.build(provider_id).unwrap_or_else(|error| { + eprintln!("{error}"); + std::process::exit(1); + }); match service.search(query).await { Ok(response) => { From 0b6eeacda9377c570e9a0189569bd80fb894075d Mon Sep 17 00:00:00 2001 From: larock22 Date: Sat, 25 Apr 2026 16:28:32 -0500 Subject: [PATCH 13/44] T006: guard bootstrap cli boundary Add an architecture test that documents bootstrap as the composition layer while forbidding CLI imports there. Refs: .artifacts/plan/2026-04-25_13-50-32_provider-registry/PLAN.md --- .../execute/2026-04-25_21-24-49_provider-registry.md | 12 ++++++------ tests/architecture_test.rs | 6 ++++++ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md b/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md index 6430947..49b2a98 100644 --- a/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md +++ b/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md @@ -58,19 +58,19 @@ env: {target: "local", notes: "Rust CLI local implementation"} ### T005 - Replace inline provider construction in main - Status: completed -- Commit: pending +- Commit: 5129cf5 - Files: src/main.rs - Commands: `cargo check` -> pass; `env -u BRAVE_API_KEY -u EXA_API_KEY cargo run -- "rust"` -> exits 1 with `provider `brave` is unavailable; configured providers: []` - Tests: missing-key CLI acceptance proof pass - Notes: Main now builds the selected service through the production registry before search execution. ### T006 - Extend architecture guardrails for the composition layer -- Status: pending +- Status: completed - Commit: pending -- Files: pending -- Commands: pending -- Tests: pending -- Notes: pending +- Files: tests/architecture_test.rs +- Commands: `cargo test --test architecture_test` -> pass +- Tests: architecture boundary tests pass, including bootstrap no-CLI guard +- Notes: Existing layer checks unchanged; bootstrap remains allowed to compose app, providers, transport, and domain. ### T007 - Add focused registry tests - Status: pending diff --git a/tests/architecture_test.rs b/tests/architecture_test.rs index a5e566d..aef065b 100644 --- a/tests/architecture_test.rs +++ b/tests/architecture_test.rs @@ -34,6 +34,12 @@ fn test_app_does_not_import_cli() { check_dir_for_forbidden_patterns("src/app", &forbidden); } +#[test] +fn test_bootstrap_does_not_import_cli() { + let forbidden = ["use crate::cli::"]; + check_dir_for_forbidden_patterns("src/bootstrap", &forbidden); +} + #[test] fn test_render_text_only_called_from_cli() { let forbidden_dirs = ["src/domain", "src/transport", "src/providers", "src/app"]; From 7ada3e11a70eea4a13418f90e4884b90844c8810 Mon Sep 17 00:00:00 2001 From: larock22 Date: Sat, 25 Apr 2026 16:29:00 -0500 Subject: [PATCH 14/44] T007: add provider registry tests Cover pure registry registration, service construction, stable availability ordering, and keep env-backed availability separate. Refs: .artifacts/plan/2026-04-25_13-50-32_provider-registry/PLAN.md --- .../2026-04-25_21-24-49_provider-registry.md | 12 ++-- src/bootstrap/provider_registry.rs | 57 +++++++++++++++++++ 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md b/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md index 49b2a98..e897b1c 100644 --- a/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md +++ b/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md @@ -66,19 +66,19 @@ env: {target: "local", notes: "Rust CLI local implementation"} ### T006 - Extend architecture guardrails for the composition layer - Status: completed -- Commit: pending +- Commit: 0b6eeac - Files: tests/architecture_test.rs - Commands: `cargo test --test architecture_test` -> pass - Tests: architecture boundary tests pass, including bootstrap no-CLI guard - Notes: Existing layer checks unchanged; bootstrap remains allowed to compose app, providers, transport, and domain. ### T007 - Add focused registry tests -- Status: pending +- Status: completed - Commit: pending -- Files: pending -- Commands: pending -- Tests: pending -- Notes: pending +- Files: src/bootstrap/provider_registry.rs +- Commands: `cargo test bootstrap::provider_registry` -> pass +- Tests: registry unavailable, env availability, successful build, and stable ordering tests pass +- Notes: Pure registry tests use a local MockProvider and remain separate from env mutation test. ### T008 - Run final local gate and update docs only if source references require it - Status: pending diff --git a/src/bootstrap/provider_registry.rs b/src/bootstrap/provider_registry.rs index 94bf6ed..36f2fbd 100644 --- a/src/bootstrap/provider_registry.rs +++ b/src/bootstrap/provider_registry.rs @@ -103,9 +103,45 @@ impl ProviderRegistry { #[cfg(test)] mod tests { use super::*; + use crate::domain::error::SearchError; + use crate::domain::provider::ProviderCapabilities; + use crate::domain::query::SearchQuery; + use crate::domain::result::SearchResponse; + use async_trait::async_trait; use std::ffi::OsString; use std::sync::{Mutex, OnceLock}; + struct MockProvider; + + #[async_trait] + impl SearchProvider for MockProvider { + fn id(&self) -> String { + "mock".to_string() + } + + fn capabilities(&self) -> ProviderCapabilities { + ProviderCapabilities { + web: true, + news: true, + images: false, + videos: false, + pagination: false, + safe_search: false, + time_range_filter: false, + } + } + + async fn search(&self, _query: &SearchQuery) -> Result { + Ok(SearchResponse { + query: "mock".to_string(), + provider: "mock".to_string(), + results: vec![], + total_estimated: None, + next_page: None, + }) + } + } + fn env_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) @@ -156,4 +192,25 @@ mod tests { assert_eq!(registry.available_providers(), vec![ProviderId::Brave]); } + + #[test] + fn registered_provider_is_available_and_builds_service() { + let mut registry = ProviderRegistry::empty(); + registry.register(ProviderId::Brave, Box::new(|| Box::new(MockProvider))); + + assert_eq!(registry.available_providers(), vec![ProviderId::Brave]); + assert!(registry.build(ProviderId::Brave).is_ok()); + } + + #[test] + fn available_providers_are_returned_in_stable_order() { + let mut registry = ProviderRegistry::empty(); + registry.register(ProviderId::Exa, Box::new(|| Box::new(MockProvider))); + registry.register(ProviderId::Brave, Box::new(|| Box::new(MockProvider))); + + assert_eq!( + registry.available_providers(), + vec![ProviderId::Brave, ProviderId::Exa] + ); + } } From e9b971c6406b156df030e8acdfb5f94f4a02b03b Mon Sep 17 00:00:00 2001 From: larock22 Date: Sat, 25 Apr 2026 16:31:43 -0500 Subject: [PATCH 15/44] T008: run final gate and update docs Run the planned formatter, test, clippy, docs, and umbrella gates; update the architecture and harness documentation for the new bootstrap layer. Refs: .artifacts/plan/2026-04-25_13-50-32_provider-registry/PLAN.md --- .../2026-04-25_21-24-49_provider-registry.md | 33 ++++++++++--------- HARNESS.md | 6 ++-- docs/architecture.md | 14 +++++--- src/bootstrap/provider_registry.rs | 13 ++++---- 4 files changed, 36 insertions(+), 30 deletions(-) diff --git a/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md b/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md index e897b1c..ba0e734 100644 --- a/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md +++ b/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md @@ -11,6 +11,7 @@ owner: "tuna" plan_path: ".artifacts/plan/2026-04-25_13-50-32_provider-registry/PLAN.md" start_commit: "39c796e" rollback_commit: "7267aaa" +end_commit: "final T008 commit" env: {target: "local", notes: "Rust CLI local implementation"} --- @@ -74,31 +75,31 @@ env: {target: "local", notes: "Rust CLI local implementation"} ### T007 - Add focused registry tests - Status: completed -- Commit: pending +- Commit: 7ada3e1 - Files: src/bootstrap/provider_registry.rs - Commands: `cargo test bootstrap::provider_registry` -> pass - Tests: registry unavailable, env availability, successful build, and stable ordering tests pass - Notes: Pure registry tests use a local MockProvider and remain separate from env mutation test. ### T008 - Run final local gate and update docs only if source references require it -- Status: pending -- Commit: pending -- Files: pending -- Commands: pending -- Tests: pending -- Notes: pending +- Status: completed +- Commit: final T008 commit +- Files: HARNESS.md; docs/architecture.md; src/bootstrap/provider_registry.rs +- Commands: `cargo fmt` -> pass; `cargo test` -> 22 unit tests and 6 architecture tests pass; `cargo clippy -- -D warnings -W clippy::complexity -W clippy::cognitive_complexity` -> pass; `mdbook build` -> pass after installing missing mdBook tool; `just check` -> pass +- Tests: full repository gate pass +- Notes: Updated architecture docs and HARNESS rows affected by the new bootstrap layer. Initial `mdbook build` failed because `mdbook` was not installed; installed `mdbook v0.5.2` with `cargo install mdbook --locked`, then docs and umbrella gates passed. ## Gate Results -- Tests: pending -- Type checks: pending -- Linters: pending -- Docs build: pending -- Umbrella gate: pending +- Tests: pass (`cargo test`: 22 unit tests + 6 architecture tests) +- Type checks: pass via `cargo clippy` +- Linters: pass (`cargo fmt --check` and clippy in `just check`) +- Docs build: pass (`mdbook build`) +- Umbrella gate: pass (`just check`) ## Issues & Resolutions -- None yet +- T008 - `mdbook` was missing from PATH -> installed `mdbook v0.5.2` using Cargo and reran docs/umbrella gates successfully. ## Success Criteria -- [ ] All planned gates passed -- [ ] Execution log saved -- [ ] Source references updated only where required +- [x] All planned gates passed +- [x] Execution log saved +- [x] Source references updated only where required diff --git a/HARNESS.md b/HARNESS.md index ef03c53..dd62ddc 100644 --- a/HARNESS.md +++ b/HARNESS.md @@ -37,6 +37,7 @@ There is no Makefile, npm script, or other local entrypoint. `just check` is the | transport-isolation | `src/transport/` | `crate::providers`, `crate::cli`, `crate::app` | `tests/architecture_test.rs` | | provider-isolation | `src/providers/` | `crate::cli`, `crate::app` | `tests/architecture_test.rs` | | app-isolation | `src/app/` | `crate::cli` | `tests/architecture_test.rs` | +| bootstrap-isolation | `src/bootstrap/` | `crate::cli` | `tests/architecture_test.rs` | | render_text-isolation | all except `src/cli/` | `render_text` | `tests/architecture_test.rs` | Architecture boundary tests run as part of `cargo test`. @@ -47,12 +48,12 @@ No structural rule engine is currently configured (no ast-grep, semgrep, or cust ### Layer 4: Behavioral Verification | Test Suite | Command | Location | Notes | |------------|---------|----------|-------| -| Unit tests (inline) | `cargo test` | `src/**/*.rs` under `#[cfg(test)]` | 7 tests across 4 modules | +| Unit tests (inline) | `cargo test` | `src/**/*.rs` under `#[cfg(test)]` | 22 tests across source modules | | Mapper tests | `cargo test` | `src/providers/brave/mapper.rs` | 4 tests: web, news, images, videos DTO→domain mapping | | Provider tests | `cargo test` | `src/providers/brave/client.rs` | 1 mock-HTTP test for `BraveProvider::search` | | App-layer tests | `cargo test` | `src/app/search_service.rs` | 1 mock-provider test for `SearchService` delegation | | Output tests | `cargo test` | `src/cli/output.rs` | 1 text-rendering test with mixed result types | -| Architecture tests | `cargo test` | `tests/architecture_test.rs` | 5 source-scan tests enforcing layer boundaries | +| Architecture tests | `cargo test` | `tests/architecture_test.rs` | 6 source-scan tests enforcing layer boundaries | No snapshot, golden, or integration test suites exist. @@ -108,6 +109,7 @@ Ordered list of checks as executed by the canonical entry point: | `docs/` | mdBook source: intro, architecture, quickstart | | `book.toml` | mdBook configuration | | `Cargo.toml` | Project manifest, dependencies, edition 2024 | +| `src/bootstrap/provider_registry.rs` | Built-in provider registry and service construction tests | | `src/providers/brave/mapper.rs` | 4 unit tests for DTO→domain mapping | | `src/providers/brave/client.rs` | 1 mock-HTTP unit test for Brave provider | | `src/app/search_service.rs` | 1 mock-provider unit test for SearchService | diff --git a/docs/architecture.md b/docs/architecture.md index ecf35b0..d8419fe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,7 +2,7 @@ ## Design principle -The codebase is split into four strictly ordered layers. **No layer may import from a layer above it.** This prevents the CLI from leaking into the domain and keeps providers interchangeable. +The codebase is split into four strictly ordered runtime layers plus a narrow bootstrap composition layer. **No runtime layer may import from a layer above it.** This prevents the CLI from leaking into the domain and keeps providers interchangeable. ``` cli (top) @@ -14,6 +14,8 @@ providers transport ↑ domain (bottom) + +bootstrap composes app + providers + transport at startup ``` ## Full request lifecycle @@ -24,7 +26,8 @@ domain (bottom) 2. src/main.rs └── CliArgs::parse() produces CliArgs { query, provider, search_type, limit, ... } - └── selects BraveProvider or ExaProvider + └── converts CliProvider to ProviderId + └── asks the bootstrap ProviderRegistry to build SearchService └── maps CliArgs → SearchQuery 3. src/app/search_service.rs @@ -162,10 +165,10 @@ Unsupported Exa inputs are rejected at runtime instead of being ignored: `Images ## Runtime provider selection -`main.rs` remains the only place that chooses a concrete provider. `SearchQuery`, `SearchResponse`, `SearchProvider`, and `SearchService` stay unchanged. +`main.rs` remains the binary edge that chooses the requested provider ID. Concrete provider construction lives in `src/bootstrap/provider_registry.rs`, where typed provider config, HTTP transport, provider clients, and `SearchService` are composed. -- `--provider brave` loads `BraveConfig` and constructs `BraveProvider` -- `--provider exa` loads `ExaConfig` and constructs `ExaProvider` +- `--provider brave` maps to `ProviderId::Brave`; the registry includes it only when `BRAVE_API_KEY` is configured +- `--provider exa` maps to `ProviderId::Exa`; the registry includes it only when `EXA_API_KEY` is configured - omitting `--provider` still selects Brave ## Architecture enforcement @@ -178,6 +181,7 @@ The rules are verified by `tests/architecture_test.rs`. These tests scan source | `src/transport/` | `crate::providers::`, `crate::cli::`, `crate::app::` | | `src/providers/` | `crate::cli::`, `crate::app::` | | `src/app/` | `crate::cli::` | +| `src/bootstrap/` | `crate::cli::` | | Any layer except `src/cli/` | `render_text` | Run them with the rest of the suite: diff --git a/src/bootstrap/provider_registry.rs b/src/bootstrap/provider_registry.rs index 36f2fbd..d11cc41 100644 --- a/src/bootstrap/provider_registry.rs +++ b/src/bootstrap/provider_registry.rs @@ -88,13 +88,12 @@ impl ProviderRegistry { } pub fn build(&self, provider: ProviderId) -> Result { - let builder = - self.builders - .get(&provider) - .ok_or_else(|| BuildSearchServiceError::ProviderUnavailable { - provider, - available: self.available_providers(), - })?; + let builder = self.builders.get(&provider).ok_or_else(|| { + BuildSearchServiceError::ProviderUnavailable { + provider, + available: self.available_providers(), + } + })?; Ok(SearchService::new(builder())) } From 80d28d63522de622114ede6ba2ec25172d5d05c0 Mon Sep 17 00:00:00 2001 From: larock22 Date: Sun, 26 Apr 2026 09:07:12 -0500 Subject: [PATCH 16/44] docs(changelog): add provider registry changes - Add provider registry implementation notes - Document new plan artifacts and interface designs - Update changed section with main.rs refactoring and harness updates --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d9ddc8..6a2f09f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Bootstrap**: Provider registry (`src/bootstrap/provider_registry.rs`) for compile-time provider registration and metadata discovery. +- **Plans**: Complete provider registry implementation plan with tickets T001–T008 under `.artifacts/plan/2026-04-25_13-50-32_provider-registry/`. +- **Interface Designs**: Visual HTML documentation for search service flow and interface options under `.artifacts/interface-designs/`. + ### Changed +- **Main**: Refactored to use provider registry for provider instantiation instead of direct constructor calls. +- **Architecture Tests**: Updated to allow `bootstrap` module imports from `main.rs`. +- **HARNESS.md**: Updated harness map to reflect current validation chain. - **Exa**: Default `/search` `contents` now requests **highlights** (with `maxCharacters` and the user query) and a **query-scoped summary** object instead of full-page **`text`**, so the API is not asked for article bodies for normal CLI usage. - **Exa**: Normalized `snippet` is derived as **summary** (trimmed, capped) if non-empty, else **joined highlights** (separator ` … `, capped); **`text` is never used** as a snippet fallback, even when present in the response. From fed9659821d21bc789a8b6feeea95670b968e7d0 Mon Sep 17 00:00:00 2001 From: larock22 Date: Sun, 26 Apr 2026 09:07:32 -0500 Subject: [PATCH 17/44] chore: docs --- ...ervice-provider-registry-architecture.html | 386 ++++++++++++++++++ 1 file changed, 386 insertions(+) create mode 100644 .artifacts/interface-designs/search-service-provider-registry-architecture.html diff --git a/.artifacts/interface-designs/search-service-provider-registry-architecture.html b/.artifacts/interface-designs/search-service-provider-registry-architecture.html new file mode 100644 index 0000000..5e7d9b1 --- /dev/null +++ b/.artifacts/interface-designs/search-service-provider-registry-architecture.html @@ -0,0 +1,386 @@ + + + + + + Sophon Search Service Provider Registry Architecture + + + + +
+
+
+
+

Sophon Search Service Provider Registry

+
+

+ Implemented flow after the interface design pass: the CLI selects a provider id, + bootstrap owns provider construction, and the app/domain layers keep the search path provider-agnostic. +

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + sophon-cli process + + + CLI layer + + + bootstrap + app + + + provider adapters + + + external APIs + + + ProviderId + + + build(id) + + + Box<dyn SearchProvider> + + + SearchService::new + + + SearchQuery + + + search(&query) + + + GET Brave + + + POST Exa + + + DTO JSON + + + DTO JSON + + + SearchResponse + + + render_text + + + reads configured env keys only + + + + main.rs + CliArgs::parse + about / query guard + thin command path + + + + CLI output + render_text + stdout / stderr + presentation only + + + + ProviderRegistry + production_from_env() + registers Brave / Exa builders + configured providers only + + + + ProviderBuilder + Fn() -> Box<dyn SearchProvider> + creates Reqwest client per service + construction hidden here + + + + SearchService + Box<dyn SearchProvider> + delegates search(query) + app orchestration only + + + + BraveProvider + web / news + images / videos + GET mapper + BraveConfig + + + + ExaProvider + web / news + no images/videos + POST mapper + ExaConfig + + + + HTTP + trait + Reqwest + transport + + + + Brave API + search endpoints + subscription token + BRAVE_API_KEY + + + + Exa API + /search + x-api-key + EXA_API_KEY + + + + Architecture tests guard the direction: domain stays pure; providers do not import CLI/app; bootstrap does not import CLI. + + Legend + + + CLI / frontend boundary + + + App / bootstrap + + + Provider adapter + + + External cloud/API + + + configuration/security flow + +
+ +
+
+
+
+

Implemented Decision

+
+
    +
  • • The registry pattern won the interface design pass.
  • +
  • • `main` now converts CLI provider choice into `ProviderId`.
  • +
  • • Provider-specific config and constructors live in bootstrap.
  • +
  • • Missing API keys make providers unavailable, not half-built.
  • +
+
+ +
+
+
+

Boundary Shape

+
+
    +
  • • `SearchService` still depends only on `SearchProvider`.
  • +
  • • Brave and Exa map their DTOs into domain responses.
  • +
  • • `HttpClient` keeps transport testable behind a trait.
  • +
  • • Architecture tests lock import direction into place.
  • +
+
+ +
+
+
+

Runtime Flow

+
+
    +
  • • CLI builds a `SearchQuery`, then asks the registry for a service.
  • +
  • • The chosen provider performs the external API call.
  • +
  • • Provider mappers normalize the response before rendering.
  • +
  • • Errors stay explicit at the command boundary.
  • +
+
+
+ + +
+ + From a97ea2c8cfd957983bc3521e6044cdf940c5cf47 Mon Sep 17 00:00:00 2001 From: larock22 Date: Sun, 26 Apr 2026 13:00:00 -0500 Subject: [PATCH 18/44] feat: add structured logging with tracing (#8) * feat: add structured logging with tracing Add tracing and tracing-subscriber for structured, environment-filtered logs across the CLI, application layer, transport, and provider adapters. Logs are written to stderr so stdout remains clean for CLI results. Key spans instrument startup, search orchestration, provider calls, and HTTP transport. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * fix: avoid logging auth headers --------- Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- AGENTS.md | 6 ++ Cargo.lock | 118 ++++++++++++++++++++++++++++++++++ Cargo.toml | 2 + src/app/search_service.rs | 8 ++- src/main.rs | 11 ++++ src/providers/brave/client.rs | 1 + src/providers/exa/client.rs | 1 + src/transport/http.rs | 4 ++ 8 files changed, 150 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 359eafb..93a3123 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,12 @@ - `justfile` — canonical local check gate - `Cargo.toml` — dependencies and edition 2024 +## Observability +- Structured logging is provided by `tracing` (with `tracing-subscriber` formatting). +- Log output is written to **stderr** so stdout remains clean for CLI results. +- Control verbosity via the `RUST_LOG` environment variable (e.g. `RUST_LOG=debug`). +- Key spans: `main` (startup), `SearchService::search` (orchestration), `BraveProvider::search` / `ExaProvider::search` (provider adapters), `ReqwestHttpClient::{get_json,post_json}` (transport). + ## Change Guardrails - Run `just check` before committing. - Keep domain types provider-agnostic; add provider-specific logic in `src/providers/`. diff --git a/Cargo.lock b/Cargo.lock index eb2bfac..56408f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -707,6 +716,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -746,6 +761,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.8.0" @@ -786,6 +810,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -944,6 +977,23 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "reqwest" version = "0.12.28" @@ -1155,6 +1205,15 @@ dependencies = [ "serde", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1206,6 +1265,8 @@ dependencies = [ "serde_json", "thiserror", "tokio", + "tracing", + "tracing-subscriber", ] [[package]] @@ -1311,6 +1372,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1434,9 +1504,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -1444,6 +1526,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -1494,6 +1606,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/Cargo.toml b/Cargo.toml index 0b28afe..3b651c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,3 +19,5 @@ async-trait = "0.1" clap = { version = "4", features = ["derive"] } dotenvy = "0.15" chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/src/app/search_service.rs b/src/app/search_service.rs index ed06923..91bd6d1 100644 --- a/src/app/search_service.rs +++ b/src/app/search_service.rs @@ -12,8 +12,14 @@ impl SearchService { Self { provider } } + #[tracing::instrument(skip(self), fields(query = %query.text, provider = %self.provider.id()))] pub async fn search(&self, query: SearchQuery) -> Result { - self.provider.search(&query).await + tracing::debug!("delegating search to provider"); + let result = self.provider.search(&query).await; + if let Err(ref e) = result { + tracing::warn!(error = %e, "provider search returned error"); + } + result } } diff --git a/src/main.rs b/src/main.rs index 29b6f10..17dabeb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ use clap::Parser; use cli::args::{CliArgs, CliProvider}; use cli::output::render_text; use domain::query::SearchQuery; +use tracing_subscriber::EnvFilter; impl From for ProviderId { fn from(provider: CliProvider) -> Self { @@ -22,6 +23,11 @@ impl From for ProviderId { #[tokio::main] async fn main() { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .with_writer(std::io::stderr) + .init(); + dotenvy::dotenv().ok(); let args = CliArgs::parse(); @@ -58,17 +64,22 @@ async fn main() { }; let provider_id = ProviderId::from(args.provider); + tracing::info!(provider = %provider_id, query = %query.text, "initializing search service"); + let registry = ProviderRegistry::production_from_env(); let service = registry.build(provider_id).unwrap_or_else(|error| { + tracing::error!(%error, "failed to build provider"); eprintln!("{error}"); std::process::exit(1); }); match service.search(query).await { Ok(response) => { + tracing::info!(result_count = response.results.len(), total_estimated = ?response.total_estimated, "search completed"); println!("{}", render_text(&response)); } Err(e) => { + tracing::error!(error = %e, "search failed"); eprintln!("Search failed: {}", e); std::process::exit(1); } diff --git a/src/providers/brave/client.rs b/src/providers/brave/client.rs index f2c4604..4c4b017 100644 --- a/src/providers/brave/client.rs +++ b/src/providers/brave/client.rs @@ -38,6 +38,7 @@ impl SearchProvider for BraveProvider { } } + #[tracing::instrument(skip(self), fields(query = %query.text, search_type = ?query.search_type))] async fn search(&self, query: &SearchQuery) -> Result { let endpoint = match query.search_type { SearchType::Web => "web/search", diff --git a/src/providers/exa/client.rs b/src/providers/exa/client.rs index f93ee19..e1acf28 100644 --- a/src/providers/exa/client.rs +++ b/src/providers/exa/client.rs @@ -105,6 +105,7 @@ impl SearchProvider for ExaProvider { } } + #[tracing::instrument(skip(self), fields(query = %query.text, search_type = ?query.search_type))] async fn search(&self, query: &SearchQuery) -> Result { let request = self.build_request(query)?; let response: ExaSearchResponse = self diff --git a/src/transport/http.rs b/src/transport/http.rs index 64ac382..c5970b1 100644 --- a/src/transport/http.rs +++ b/src/transport/http.rs @@ -66,6 +66,7 @@ impl ReqwestHttpClient { #[async_trait] impl HttpClient for ReqwestHttpClient { + #[tracing::instrument(skip(self, headers), fields(url = %url))] async fn get_json( &self, url: &str, @@ -85,9 +86,11 @@ impl HttpClient for ReqwestHttpClient { .await .map_err(|e| SearchError::Transport(e.to_string()))?; + tracing::debug!(status = %resp.status(), "received HTTP response"); Self::decode_response(resp).await } + #[tracing::instrument(skip(self, headers, body), fields(url = %url))] async fn post_json( &self, url: &str, @@ -108,6 +111,7 @@ impl HttpClient for ReqwestHttpClient { .await .map_err(|e| SearchError::Transport(e.to_string()))?; + tracing::debug!(status = %resp.status(), "received HTTP response"); Self::decode_response(resp).await } } From 4d8e5e1ef8daf9fd87feb13f335cfd956477f5bd Mon Sep 17 00:00:00 2001 From: larock22 Date: Sun, 26 Apr 2026 13:10:55 -0500 Subject: [PATCH 19/44] [codex] Add docs metadata guard (#9) * add docs metadata guard * fix docs metadata CI guard --- .artifacts/execute/2026-04-14_search-cli.md | 176 --- .../2026-04-15_16-35-04_exa-provider.md | 98 -- .../2026-04-25_21-24-49_provider-registry.md | 105 -- .../search-service-flow-visual.html | 289 ----- .../search-service-interface-options.html | 151 --- ...ervice-provider-registry-architecture.html | 386 ------ .artifacts/plan/2026-04-14_search-cli/PLAN.md | 1075 ----------------- .../2026-04-14_search-cli/tickets/INDEX.md | 24 - .../2026-04-14_search-cli/tickets/T001.md | 45 - .../2026-04-14_search-cli/tickets/T002.md | 145 --- .../2026-04-14_search-cli/tickets/T003.md | 96 -- .../2026-04-14_search-cli/tickets/T004.md | 57 - .../2026-04-14_search-cli/tickets/T005.md | 131 -- .../2026-04-14_search-cli/tickets/T006.md | 56 - .../2026-04-14_search-cli/tickets/T007.md | 160 --- .../2026-04-14_search-cli/tickets/T008.md | 54 - .../2026-04-14_search-cli/tickets/T009.md | 110 -- .../2026-04-14_search-cli/tickets/T010.md | 72 -- .../2026-04-14_search-cli/tickets/T011.md | 92 -- .../2026-04-14_search-cli/tickets/T012.md | 62 - .../2026-04-15_16-22-21_exa-provider/PLAN.md | 311 ----- .../tickets/INDEX.md | 18 - .../tickets/T001.md | 32 - .../tickets/T002.md | 41 - .../tickets/T003.md | 33 - .../tickets/T004.md | 56 - .../tickets/T005.md | 36 - .../tickets/T006.md | 75 -- .../PLAN.md | 336 ------ .../tickets/INDEX.md | 20 - .../tickets/T001.md | 35 - .../tickets/T002.md | 36 - .../tickets/T003.md | 35 - .../tickets/T004.md | 32 - .../tickets/T005.md | 35 - .../tickets/T006.md | 32 - .../tickets/T007.md | 32 - .../tickets/T008.md | 74 -- ...04-15_21-10-50_provider-integration-map.md | 125 -- ...4-15_21-19-47_exa-provider-contract-map.md | 203 ---- .cargo-husky/hooks/pre-push | 5 + .factory/skills/sophon-cli/SKILL.md | 10 + .github/ISSUE_TEMPLATE/bug_report.md | 9 + .github/ISSUE_TEMPLATE/feature_request.md | 9 + .github/pull_request_template.md | 12 + .github/workflows/validate-agents.yml | 19 +- .gitignore | 2 +- .pre-commit-config.yaml | 17 - AGENTS.md | 2 +- CHANGELOG.md | 16 +- Cargo.lock | 7 + Cargo.toml | 5 + HARNESS.md | 61 +- book.toml | 4 + docs/architecture.md | 12 + docs/intro.md | 12 + docs/quickstart.md | 12 + justfile | 3 +- scripts/check_markdown_frontmatter.py | 133 ++ scripts/mdbook_strip_frontmatter.py | 50 + 60 files changed, 341 insertions(+), 5040 deletions(-) delete mode 100644 .artifacts/execute/2026-04-14_search-cli.md delete mode 100644 .artifacts/execute/2026-04-15_16-35-04_exa-provider.md delete mode 100644 .artifacts/execute/2026-04-25_21-24-49_provider-registry.md delete mode 100644 .artifacts/interface-designs/search-service-flow-visual.html delete mode 100644 .artifacts/interface-designs/search-service-interface-options.html delete mode 100644 .artifacts/interface-designs/search-service-provider-registry-architecture.html delete mode 100644 .artifacts/plan/2026-04-14_search-cli/PLAN.md delete mode 100644 .artifacts/plan/2026-04-14_search-cli/tickets/INDEX.md delete mode 100644 .artifacts/plan/2026-04-14_search-cli/tickets/T001.md delete mode 100644 .artifacts/plan/2026-04-14_search-cli/tickets/T002.md delete mode 100644 .artifacts/plan/2026-04-14_search-cli/tickets/T003.md delete mode 100644 .artifacts/plan/2026-04-14_search-cli/tickets/T004.md delete mode 100644 .artifacts/plan/2026-04-14_search-cli/tickets/T005.md delete mode 100644 .artifacts/plan/2026-04-14_search-cli/tickets/T006.md delete mode 100644 .artifacts/plan/2026-04-14_search-cli/tickets/T007.md delete mode 100644 .artifacts/plan/2026-04-14_search-cli/tickets/T008.md delete mode 100644 .artifacts/plan/2026-04-14_search-cli/tickets/T009.md delete mode 100644 .artifacts/plan/2026-04-14_search-cli/tickets/T010.md delete mode 100644 .artifacts/plan/2026-04-14_search-cli/tickets/T011.md delete mode 100644 .artifacts/plan/2026-04-14_search-cli/tickets/T012.md delete mode 100644 .artifacts/plan/2026-04-15_16-22-21_exa-provider/PLAN.md delete mode 100644 .artifacts/plan/2026-04-15_16-22-21_exa-provider/tickets/INDEX.md delete mode 100644 .artifacts/plan/2026-04-15_16-22-21_exa-provider/tickets/T001.md delete mode 100644 .artifacts/plan/2026-04-15_16-22-21_exa-provider/tickets/T002.md delete mode 100644 .artifacts/plan/2026-04-15_16-22-21_exa-provider/tickets/T003.md delete mode 100644 .artifacts/plan/2026-04-15_16-22-21_exa-provider/tickets/T004.md delete mode 100644 .artifacts/plan/2026-04-15_16-22-21_exa-provider/tickets/T005.md delete mode 100644 .artifacts/plan/2026-04-15_16-22-21_exa-provider/tickets/T006.md delete mode 100644 .artifacts/plan/2026-04-25_13-50-32_provider-registry/PLAN.md delete mode 100644 .artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/INDEX.md delete mode 100644 .artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T001.md delete mode 100644 .artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T002.md delete mode 100644 .artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T003.md delete mode 100644 .artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T004.md delete mode 100644 .artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T005.md delete mode 100644 .artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T006.md delete mode 100644 .artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T007.md delete mode 100644 .artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T008.md delete mode 100644 .artifacts/research/2026-04-15_21-10-50_provider-integration-map.md delete mode 100644 .artifacts/research/2026-04-15_21-19-47_exa-provider-contract-map.md create mode 100755 .cargo-husky/hooks/pre-push delete mode 100644 .pre-commit-config.yaml create mode 100755 scripts/check_markdown_frontmatter.py create mode 100644 scripts/mdbook_strip_frontmatter.py diff --git a/.artifacts/execute/2026-04-14_search-cli.md b/.artifacts/execute/2026-04-14_search-cli.md deleted file mode 100644 index 4cd7294..0000000 --- a/.artifacts/execute/2026-04-14_search-cli.md +++ /dev/null @@ -1,176 +0,0 @@ ---- -title: "search-cli execution log" -link: "search-cli-execute" -type: debug_history -ontological_relations: - - relates_to: [[search-cli-plan]] -tags: [execute, search-cli, rust] -uuid: "b2c3d4e5-f6a7-8901-bcde-f23456789012" -created_at: "2026-04-14T12:50:00Z" -plan_path: ".artifacts/plan/2026-04-14_search-cli/PLAN.md" -start_commit: "dee9ad9" -env: {target: "local", notes: ""} ---- - -## Pre-Flight Checks -- Branch: main -- Rollback commit: dee9ad9 -- DoR satisfied: yes -- Access/secrets: present (BRAVE_API_KEY in .env) -- Fixtures/data: ready -- Ready: yes - -## Task Execution - -### T001 – Bootstrap Rust project and dependencies -- Status: completed -- Commit: a974611 -- Files: Cargo.toml, src/main.rs -- Commands: cargo check → success -- Tests: n/a -- Notes: initialized cargo project and added all deps - -### T002 – Implement domain core types -- Status: completed -- Commit: d9d6f23 -- Files: src/domain/mod.rs, src/domain/types.rs, src/domain/query.rs, src/domain/result.rs, src/domain/error.rs -- Commands: cargo check → success (12 dead_code warnings expected) -- Tests: n/a -- Notes: domain layer complete - -### T003 – Implement transport layer (HttpClient trait + reqwest adapter) -- Status: completed -- Commit: 75546e2 -- Files: src/transport/mod.rs, src/transport/http.rs -- Commands: cargo check → success (15 dead_code warnings expected) -- Tests: n/a -- Notes: fixed borrow-checker issue by extracting status before resp.text() - -## Gate Results -- Tests: n/a (no tests yet per plan) -- Type checks: cargo check passes -- Linters: n/a - -## Success Criteria -- [x] T001 completed -- [x] T002 completed -- [x] T003 completed -- [x] Execution log saved - -### Harness – Add justfile with check recipe -- Status: completed -- Commit: ae7335d -- Files: justfile, src/main.rs -- Commands: - - cargo fmt --check → pass - - cargo clippy -- -D warnings -W clippy::complexity -W clippy::cognitive_complexity → pass - - cargo test → pass (0 tests) -- Notes: added `justfile` with `check` recipe; `just` is not installed locally but the recipe is ready. Added `#![allow(dead_code)]` to main.rs as a temporary measure while the codebase is partially built. - -### T004 – Implement SearchProvider trait and capabilities -- Status: completed -- Commit: 01bb1ca -- Files: src/domain/mod.rs, src/domain/provider.rs -- Commands: cargo check → success -- Tests: n/a -- Notes: trait and capabilities defined - -### T005 – Implement Brave DTOs -- Status: completed -- Commit: 8fca979 -- Files: src/providers/mod.rs, src/providers/brave/mod.rs, src/providers/brave/dto.rs -- Commands: cargo check → success -- Tests: n/a -- Notes: created DTOs with Deserialize; added placeholder files for client/config/mapper - -### T006 – Implement Brave mapper -- Status: completed -- Commit: d8a90c5 -- Files: src/providers/brave/mapper.rs -- Commands: cargo test → 4 passed -- Tests: pass -- Notes: fixed borrow-checker issue by extracting total_estimated before consuming dto.web - -### T007 – Implement BraveProvider -- Status: completed -- Commit: c073686 -- Files: src/providers/brave/client.rs, src/providers/brave/config.rs -- Commands: cargo test → 5 passed -- Tests: pass (mock HTTP test) -- Real API test: ✅ `cargo run -- "rust programming"` returned 3 web results from Brave -- Notes: fixed .env format (removed trailing "brave"). main.rs restored to plan state after ad-hoc real test. - -### T008 – Implement SearchService -- Status: completed -- Commit: 3139ed6 -- Files: src/app/mod.rs, src/app/search_service.rs -- Commands: cargo test → 6 passed -- Tests: pass (mock provider delegation) -- Notes: SearchService created with Box - -### T009 – Implement CLI argument parsing -- Status: completed -- Commit: 5a07f88 -- Files: src/cli/mod.rs, src/cli/args.rs -- Commands: cargo run -- --help → displays options; cargo run -- "rust" → prints parsed args -- Tests: n/a -- Notes: added CliSearchType and CliSafeSearch with manual mapping to domain types - -### T010 – Implement output rendering -- Status: completed -- Commit: 6b0e92a -- Files: src/cli/output.rs -- Commands: cargo test → 7 passed -- Tests: pass (mixed results text rendering) -- Notes: renderer handles all four search result types - -### T011 – Wire main.rs and run end-to-end -- Status: completed -- Commit: 3431087 -- Files: src/main.rs -- Commands: cargo run -- "rust programming" → returned 20 web results from Brave -- Tests: pass (live E2E web search) -- Notes: user scoped out images/videos E2E verification; web search confirmed working - -### T012 – Add boundary tests and CI-ready verification -- Status: completed -- Commit: 832e24d -- Files: src/providers/brave/mapper.rs, src/providers/brave/client.rs, src/app/search_service.rs, src/domain/error.rs, src/domain/provider.rs, src/domain/types.rs -- Commands: - - cargo fmt --check → pass - - cargo clippy -- -D warnings -W clippy::complexity -W clippy::cognitive_complexity → pass - - cargo test → 7 passed -- Tests: pass (mapper 4, provider 1, service 1, renderer 1) -- Notes: added #[allow(dead_code)] to domain items planned for future use (TimeRange, ProviderCapabilities, etc.) - -## Gate Results -- Tests: 7/7 passed -- Coverage: n/a (no coverage tool configured) -- Type checks: cargo check → pass -- Linters: cargo clippy with -D warnings + complexity lints → pass -- Format: cargo fmt --check → pass - -## Issues & Resolutions -- T012 – clippy dead_code failures on domain types → added targeted #[allow(dead_code)] attributes - -## Success Criteria -- [x] All planned gates passed -- [x] Execution log saved -- [x] T004–T012 completed - -### Post-T012 – Architecture enforcement -- Status: completed -- Commit: 32f852e -- Files: tests/architecture_test.rs, architecture-report.html -- Commands: cargo test → 12 passed (7 unit + 5 architecture) -- Tests: pass - - domain isolation - - transport isolation - - provider isolation - - app isolation - - render_text isolation -- Notes: HARNESS.md and AGENTS.md updated to reflect enforcement layer - -## Next Steps -- QA review or extend CLI with additional providers/features - diff --git a/.artifacts/execute/2026-04-15_16-35-04_exa-provider.md b/.artifacts/execute/2026-04-15_16-35-04_exa-provider.md deleted file mode 100644 index c5429fd..0000000 --- a/.artifacts/execute/2026-04-15_16-35-04_exa-provider.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -title: "exa-provider execution log" -link: "exa-provider-execute" -type: debug_history -ontological_relations: - - relates_to: [[exa-provider-implementation-plan]] -tags: [execute, exa-provider] -uuid: "c3722e53-79f7-4e5f-9749-fe82838c4b10" -created_at: "2026-04-15T21:35:04Z" -owner: "fabian" -plan_path: ".artifacts/plan/2026-04-15_16-22-21_exa-provider/PLAN.md" -start_commit: "78610af" -env: {target: "local", notes: "Executing against local checkout only."} ---- - -## Pre-Flight Checks -- Branch: exa -- Rollback commit: 2d1593b -- DoR satisfied: yes -- Access/secrets: present (`EXA_API_KEY` in `.env`) -- Fixtures/data: ready -- Ready: yes - -## Task Execution - -### T001 – Extend shared HTTP transport for JSON POST requests -- Status: completed -- Commit: d0bf480 -- Files: `src/transport/http.rs` -- Commands: `cargo test transport::http::tests::test_post_json_decodes_success_response` -> pass -- Tests: pass -- Coverage delta: not measured -- Notes: Added generic `post_json`, shared reqwest response decoding, and a local TCP listener POST regression. The new trait method carries a default error implementation so existing mocks keep compiling until the explicit T006 mock update. - -### T002 – Add Exa provider scaffolding, config, and serde DTOs -- Status: completed -- Commit: 39900d3 -- Files: `src/providers/mod.rs`, `src/providers/exa/mod.rs`, `src/providers/exa/config.rs`, `src/providers/exa/dto.rs`, `src/providers/exa/client.rs`, `src/providers/exa/mapper.rs` -- Commands: `cargo test providers::exa::dto::tests::test_exa_search_response_deserializes_minimal_payload` -> pass -- Tests: pass -- Coverage delta: not measured -- Notes: Added the Exa module export, env-backed config, narrow request/response DTOs, and placeholder `client`/`mapper` modules so the scaffolding compiles cleanly before T003/T004 fill them in. - -### T003 – Map Exa responses into the existing domain result model -- Status: completed -- Commit: f9a1255 -- Files: `src/providers/exa/mapper.rs` -- Commands: `cargo test providers::exa::mapper::tests::test_map_news_response_prefers_summary_and_preserves_author` -> pass -- Tests: pass -- Coverage delta: not measured -- Notes: Added `map_web_response` and `map_news_response`, kept the original query string outside the DTO boundary, preferred `summary` over `text`, and left `total_estimated`/`next_page` unset for Exa. - -### T004 – Implement the Exa provider client and query translation -- Status: completed -- Commit: 050636c -- Files: `Cargo.toml`, `Cargo.lock`, `src/providers/exa/client.rs` -- Commands: `cargo test providers::exa::client::tests::test_exa_provider_news_search_posts_expected_payload` -> pass -- Tests: pass -- Coverage delta: not measured -- Notes: Added `ExaProvider`, explicit invalid-query rejection for unsupported Exa inputs, JSON POST request translation, and ISO-8601 published-date window handling using `chrono`. - -### T005 – Wire provider selection into the CLI and binary composition -- Status: completed -- Commit: f0d02bb -- Files: `src/cli/args.rs`, `src/main.rs` -- Commands: `cargo test cli::args::tests::test_cli_provider_parses_exa_and_defaults_to_brave` -> pass -- Tests: pass -- Coverage delta: not measured -- Notes: Added `--provider`/`-p`, kept Brave as the default runtime path, and moved provider-specific config loading into the `main.rs` provider match without changing `SearchQuery` construction. - -### T006 – Refresh regression coverage and architecture notes for multi-provider runtime -- Status: completed -- Commit: pending -- Files: `src/providers/brave/client.rs`, `src/providers/exa/client.rs`, `docs/architecture.md` -- Commands: `cargo test providers::exa::client::tests::test_exa_provider_rejects_unsupported_query_fields` -> pass -- Tests: pass -- Coverage delta: not measured -- Notes: Updated the Brave test mock for the expanded transport trait, added the Exa unsupported-input regression, and documented runtime provider selection plus the GET/POST split between providers. - -## Gate Results -- Tests: `cargo test` -> 13 unit tests passed, 5 architecture tests passed -- Coverage: not measured by repo gate -- Type checks: covered by `cargo clippy -- -D warnings -W clippy::complexity -W clippy::cognitive_complexity` -> pass -- Linters: `cargo fmt --check` -> pass after `cargo fmt` -- Docs: `mdbook build` -> pass -- Smoke: `cargo run -- "rust" --provider exa --search-type news --limit 1` -> pass, returned 1 Exa news result - -## Issues & Resolutions -- T006 – `just check` initially failed on `cargo fmt --check` for `src/providers/exa/client.rs` -> ran `cargo fmt` and reran the full gate successfully - -## Success Criteria -- [x] All planned gates passed -- [x] Rollout completed or rolled back -- [x] KPIs/SLOs within thresholds -- [x] Execution log saved - -## Next Steps -- QA from execute using `.artifacts/execute/2026-04-15_16-35-04_exa-provider.md` diff --git a/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md b/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md deleted file mode 100644 index ba0e734..0000000 --- a/.artifacts/execute/2026-04-25_21-24-49_provider-registry.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -title: "Provider registry execution log" -link: "provider-registry-execute" -type: debug_history -ontological_relations: - - relates_to: [[provider-registry-plan]] -tags: [execute, provider-registry] -uuid: "F4655A49-209E-4388-B038-F01351808759" -created_at: "2026-04-25T21:24:49Z" -owner: "tuna" -plan_path: ".artifacts/plan/2026-04-25_13-50-32_provider-registry/PLAN.md" -start_commit: "39c796e" -rollback_commit: "7267aaa" -end_commit: "final T008 commit" -env: {target: "local", notes: "Rust CLI local implementation"} ---- - -## Pre-Flight Checks -- Branch: redesign-main-search-interface -- Rollback: 7267aaa -- DoR: satisfied -- Ready: yes -- Access/secrets: not required for implementation; live API calls require provider env vars -- Fixtures/data: ready - -## Task Execution - -### T001 - Add bootstrap module skeleton and ProviderId -- Status: completed -- Commit: 9e0087d -- Files: src/main.rs; src/bootstrap/mod.rs; src/bootstrap/provider_registry.rs -- Commands: `cargo check` -> pass with expected temporary dead-code warning for ProviderId -- Tests: cargo check pass -- Notes: Added composition module skeleton only; production provider behavior unchanged. - -### T002 - Define registry and construction error contract -- Status: completed -- Commit: 8886c96 -- Files: src/bootstrap/provider_registry.rs -- Commands: `cargo test bootstrap::provider_registry::tests::empty_registry_reports_provider_unavailable` -> pass -- Tests: focused unavailable-provider unit test pass -- Notes: Added registry builder map, stable availability ordering, and ProviderUnavailable construction error. - -### T003 - Add production registry that only includes configured providers -- Status: completed -- Commit: d942b93 -- Files: src/bootstrap/provider_registry.rs -- Commands: `cargo test bootstrap::provider_registry` -> pass -- Tests: env-backed production availability test pass -- Notes: Registered Brave and Exa only when typed env config constructors succeed; missing or non-Unicode keys omit providers. - -### T004 - Convert CliProvider to ProviderId at the binary edge -- Status: completed -- Commit: e441cb9 -- Files: src/main.rs -- Commands: `cargo test cli::args::tests::test_cli_provider_parses_exa_and_defaults_to_brave` -> pass -- Tests: CLI provider parsing regression test pass -- Notes: Conversion lives in the binary edge; bootstrap remains CLI-independent. - -### T005 - Replace inline provider construction in main -- Status: completed -- Commit: 5129cf5 -- Files: src/main.rs -- Commands: `cargo check` -> pass; `env -u BRAVE_API_KEY -u EXA_API_KEY cargo run -- "rust"` -> exits 1 with `provider `brave` is unavailable; configured providers: []` -- Tests: missing-key CLI acceptance proof pass -- Notes: Main now builds the selected service through the production registry before search execution. - -### T006 - Extend architecture guardrails for the composition layer -- Status: completed -- Commit: 0b6eeac -- Files: tests/architecture_test.rs -- Commands: `cargo test --test architecture_test` -> pass -- Tests: architecture boundary tests pass, including bootstrap no-CLI guard -- Notes: Existing layer checks unchanged; bootstrap remains allowed to compose app, providers, transport, and domain. - -### T007 - Add focused registry tests -- Status: completed -- Commit: 7ada3e1 -- Files: src/bootstrap/provider_registry.rs -- Commands: `cargo test bootstrap::provider_registry` -> pass -- Tests: registry unavailable, env availability, successful build, and stable ordering tests pass -- Notes: Pure registry tests use a local MockProvider and remain separate from env mutation test. - -### T008 - Run final local gate and update docs only if source references require it -- Status: completed -- Commit: final T008 commit -- Files: HARNESS.md; docs/architecture.md; src/bootstrap/provider_registry.rs -- Commands: `cargo fmt` -> pass; `cargo test` -> 22 unit tests and 6 architecture tests pass; `cargo clippy -- -D warnings -W clippy::complexity -W clippy::cognitive_complexity` -> pass; `mdbook build` -> pass after installing missing mdBook tool; `just check` -> pass -- Tests: full repository gate pass -- Notes: Updated architecture docs and HARNESS rows affected by the new bootstrap layer. Initial `mdbook build` failed because `mdbook` was not installed; installed `mdbook v0.5.2` with `cargo install mdbook --locked`, then docs and umbrella gates passed. - -## Gate Results -- Tests: pass (`cargo test`: 22 unit tests + 6 architecture tests) -- Type checks: pass via `cargo clippy` -- Linters: pass (`cargo fmt --check` and clippy in `just check`) -- Docs build: pass (`mdbook build`) -- Umbrella gate: pass (`just check`) - -## Issues & Resolutions -- T008 - `mdbook` was missing from PATH -> installed `mdbook v0.5.2` using Cargo and reran docs/umbrella gates successfully. - -## Success Criteria -- [x] All planned gates passed -- [x] Execution log saved -- [x] Source references updated only where required diff --git a/.artifacts/interface-designs/search-service-flow-visual.html b/.artifacts/interface-designs/search-service-flow-visual.html deleted file mode 100644 index 865e7a3..0000000 --- a/.artifacts/interface-designs/search-service-flow-visual.html +++ /dev/null @@ -1,289 +0,0 @@ - - - - - - Search Service Wiring Visual - - - -
-

SearchService Wiring: What Actually Changes

-

- This diagram is about the construction path around provider selection. - No search request is sent until service.search(query).await. The design question is: - how much wiring should main see? -

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - Current: main is the wiring diagram - The branch does config loading, process exit, HTTP client creation, provider construction, boxing, and service construction. - - Option 1: Narrow Factory - Option 2: Registry Factory - Option 3: CLI App Runner - - - - - - - - - - - - main - args.provider - - - - match - Brave / Exa - exit on config error - - - - Config - from_env() - provider-specific - - - - HTTP - Reqwest - client wrapper - - - - Provider - BraveProvider - or ExaProvider - - - - Svc - boxed - - - Smell: the top-level command path must read every provider's construction recipe - - - - - - - - - main - parse args - handle exit - - - - build - service - one function - owns match - - - - Search - Service - - - - hidden inside factory - • BraveConfig::from_env() - • ExaConfig::from_env() - • ReqwestHttpClient::new() - • Provider constructors + Box - - - Best default - small interface, no new framework - - - - - - - - - main - selects name - handles exit - - - - Factory - registry - config source - http factory - - - - Search - Service - - - - replaceable parts - • ProviderRegistry - • ProviderConfigSource - • HttpClientFactory - • ProviderSelection::Named(...) - - - Best when growth is real - more extensible, more surface area - - - - - - - - - main - build command - render result - - - - CLI App - run(command) - build service - search - - - - Search - Response - - - - hidden in runner - • build SearchQuery/SearchCommand - • select provider - • construct service - • call service.search(...) - - - Best for command-oriented CLI - clean top level, less reusable service access - - - - CLI edge - - application/composition - - service/factory abstraction - - config/runtime - - smelly coupling - -
- -
-
-

The likely move

-

Use Option 1 first: one composition helper moves the messy provider wiring out of main while keeping the model easy to read.

-
-
-

What not to do yet

-

Do not introduce the registry unless provider count, config sources, or test injection pressure actually grows.

-
-
-

The conceptual split

-

Construction happens before the request. Execution starts at service.search(query).await.

-
-
-
- - diff --git a/.artifacts/interface-designs/search-service-interface-options.html b/.artifacts/interface-designs/search-service-interface-options.html deleted file mode 100644 index 3be3049..0000000 --- a/.artifacts/interface-designs/search-service-interface-options.html +++ /dev/null @@ -1,151 +0,0 @@ - - - - - - Search Service Interface Options - - -
-
-

Provider construction interface

-

Three Ways To Replace The Inline Match

-

The current smell is not that HTTP requests are being made in main. The smell is that main is manually wiring config, HTTP transport, concrete providers, boxing, and service construction for every provider.

-
- -
-
-
- 1 -
-

Narrow Factory

-

Smallest practical interface

-
-
- -
-

Interface

-
pub fn build_search_service(
-    provider: CliProvider,
-) -> Result<SearchService, BuildServiceError>;
-
- -
-

Caller

-
let service = build_search_service(args.provider)
-    .unwrap_or_else(exit_with_error);
-
-let response = service.search(query).await?;
-
- -

Hides

-

Provider config loading, ReqwestHttpClient, concrete provider constructors, boxing, and SearchService::new.

- -

Trade-off

-

Best fit for now. It removes duplication without inventing a provider system. The branch still exists, but it lives in one composition function instead of the CLI flow.

-
- -
-
- 2 -
-

Registry Factory

-

Most extensible shape

-
-
- -
-

Interface

-
pub struct SearchServiceFactory { ... }
-
-impl SearchServiceFactory {
-    pub fn production() -> Self;
-    pub fn with_parts(
-        registry: ProviderRegistry,
-        config: Box<dyn ProviderConfigSource>,
-        http: Box<dyn HttpClientFactory>,
-    ) -> Self;
-    pub fn build(
-        &self,
-        provider: ProviderSelection,
-    ) -> Result<SearchService, FactoryError>;
-}
-
- -
-

Caller

-
let provider = ProviderSelection::from(args.provider);
-
-let service = SearchServiceFactory::production()
-    .build(provider)
-    .unwrap_or_else(exit_with_error);
-
- -

Hides

-

All provider construction plus where config comes from, how HTTP clients are created, and how provider names map to constructors.

- -

Trade-off

-

Good if providers or config sources are about to multiply. Heavy for a two-provider CLI because it adds registry and factory abstractions before the code clearly needs them.

-
- -
-
- 3 -
-

CLI App Runner

-

Optimized for the command flow

-
-
- -
-

Interface

-
pub struct SearchCommand {
-    pub query: SearchQuery,
-    pub provider: ProviderSelection,
-}
-
-pub struct SearchCliApp;
-
-impl SearchCliApp {
-    pub fn from_env() -> Self;
-    pub async fn run(
-        self,
-        command: SearchCommand,
-    ) -> Result<SearchResponse, SearchCliAppError>;
-}
-
- -
-

Caller

-
let command = SearchCommand::from(args);
-
-match SearchCliApp::from_env().run(command).await {
-    Ok(response) => println!("{}", render_text(&response)),
-    Err(error) => exit_with_error(error),
-}
-
- -

Hides

-

Provider construction and the call into SearchService::search. main becomes parse command, run app, render or exit.

- -

Trade-off

-

Very clean for the CLI path, but it can blur composition with execution. It is less direct if other callers want a reusable SearchService.

-
-
- -
-
-

Where They Diverge

-

The narrow factory keeps the existing architecture and just moves the noisy branch out of the reading path. The registry treats provider construction as a growing subsystem. The CLI app runner moves up a level and says the interface should be a command, not a service factory.

-

The key design choice is whether the caller wants a SearchService to keep using, or whether the caller just wants to execute one CLI search.

-
- -
-

My Read

-

For this repo right now, I would start with Option 1: Narrow Factory. It is deep enough to hide the mess, small enough to understand instantly, and it leaves the current layer boundaries intact.

-

Option 2 is the future if providers/config sources grow. Option 3 is attractive if the binary should become an executable shell around a command object rather than a visible service orchestration flow.

-
-
-
- - diff --git a/.artifacts/interface-designs/search-service-provider-registry-architecture.html b/.artifacts/interface-designs/search-service-provider-registry-architecture.html deleted file mode 100644 index 5e7d9b1..0000000 --- a/.artifacts/interface-designs/search-service-provider-registry-architecture.html +++ /dev/null @@ -1,386 +0,0 @@ - - - - - - Sophon Search Service Provider Registry Architecture - - - - -
-
-
-
-

Sophon Search Service Provider Registry

-
-

- Implemented flow after the interface design pass: the CLI selects a provider id, - bootstrap owns provider construction, and the app/domain layers keep the search path provider-agnostic. -

-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - sophon-cli process - - - CLI layer - - - bootstrap + app - - - provider adapters - - - external APIs - - - ProviderId - - - build(id) - - - Box<dyn SearchProvider> - - - SearchService::new - - - SearchQuery - - - search(&query) - - - GET Brave - - - POST Exa - - - DTO JSON - - - DTO JSON - - - SearchResponse - - - render_text - - - reads configured env keys only - - - - main.rs - CliArgs::parse - about / query guard - thin command path - - - - CLI output - render_text - stdout / stderr - presentation only - - - - ProviderRegistry - production_from_env() - registers Brave / Exa builders - configured providers only - - - - ProviderBuilder - Fn() -> Box<dyn SearchProvider> - creates Reqwest client per service - construction hidden here - - - - SearchService - Box<dyn SearchProvider> - delegates search(query) - app orchestration only - - - - BraveProvider - web / news - images / videos - GET mapper - BraveConfig - - - - ExaProvider - web / news - no images/videos - POST mapper - ExaConfig - - - - HTTP - trait - Reqwest - transport - - - - Brave API - search endpoints - subscription token - BRAVE_API_KEY - - - - Exa API - /search - x-api-key - EXA_API_KEY - - - - Architecture tests guard the direction: domain stays pure; providers do not import CLI/app; bootstrap does not import CLI. - - Legend - - - CLI / frontend boundary - - - App / bootstrap - - - Provider adapter - - - External cloud/API - - - configuration/security flow - -
- -
-
-
-
-

Implemented Decision

-
-
    -
  • • The registry pattern won the interface design pass.
  • -
  • • `main` now converts CLI provider choice into `ProviderId`.
  • -
  • • Provider-specific config and constructors live in bootstrap.
  • -
  • • Missing API keys make providers unavailable, not half-built.
  • -
-
- -
-
-
-

Boundary Shape

-
-
    -
  • • `SearchService` still depends only on `SearchProvider`.
  • -
  • • Brave and Exa map their DTOs into domain responses.
  • -
  • • `HttpClient` keeps transport testable behind a trait.
  • -
  • • Architecture tests lock import direction into place.
  • -
-
- -
-
-
-

Runtime Flow

-
-
    -
  • • CLI builds a `SearchQuery`, then asks the registry for a service.
  • -
  • • The chosen provider performs the external API call.
  • -
  • • Provider mappers normalize the response before rendering.
  • -
  • • Errors stay explicit at the command boundary.
  • -
-
-
- - -
- - diff --git a/.artifacts/plan/2026-04-14_search-cli/PLAN.md b/.artifacts/plan/2026-04-14_search-cli/PLAN.md deleted file mode 100644 index 9f75918..0000000 --- a/.artifacts/plan/2026-04-14_search-cli/PLAN.md +++ /dev/null @@ -1,1075 +0,0 @@ ---- -title: "Search CLI implementation plan" -link: "search-cli-plan" -type: implementation_plan -ontological_relations: - - relates_to: [[PRD]] -tags: [plan, search-cli, rust, brave, coding] -uuid: "a1b2c3d4-e5f6-7890-abcd-ef1234567890" -created_at: "2026-04-14T12:45:00Z" -parent_research: "PRD.md" -git_commit_at_plan: "618c875" ---- - -## Goal - -Build a Rust CLI application that performs web/news/images/video searches via the Brave Search API. The architecture must be provider-agnostic at the domain and application layers, with Brave implemented as the first provider behind a trait boundary. - -**Out of scope**: CI/CD, packaging (deb/homebrew), adding additional providers (Exa/Tavily), user documentation, deployment. - -## Scope & Assumptions - -**IN scope**: -- Rust project bootstrapping with Cargo -- Domain types (`SearchQuery`, `SearchResponse`, `SearchResult`, errors) -- `SearchProvider` trait with capabilities -- `HttpClient` transport abstraction -- Brave provider implementation (DTOs, mapper, `BraveProvider`) -- Application orchestration (`SearchService`) -- CLI argument parsing with `clap` -- Text output rendering -- Basic boundary tests - -**OUT of scope**: -- Additional search providers -- JSON/table output modes (text only for v0) -- Pagination execution (token modeled but not CLI-exposed) -- Configuration files (API key read from env only) - -**Assumptions**: -- Stable Rust toolchain available -- `reqwest` + `tokio` for async HTTP -- `serde` + `serde_json` for serialization -- `thiserror` for error types -- `async-trait` for trait async methods -- `.env` file present with `BRAVE_API_KEY` (existing) - -## Deliverables - -- `Cargo.toml` and `src/main.rs` -- `src/domain/{mod.rs,query.rs,result.rs,provider.rs,error.rs,types.rs}` -- `src/providers/{mod.rs,brave/{mod.rs,client.rs,config.rs,dto.rs,mapper.rs}}` -- `src/transport/{mod.rs,http.rs}` -- `src/app/{mod.rs,search_service.rs}` -- `src/cli/{mod.rs,args.rs,output.rs}` -- Unit tests for mapper and boundary tests for service - -## Readiness - -- Repository cloned and `.env` present with `BSAqJrJYLQsHK0YGQR82odpW20MuDel brave` -- Rust toolchain installed (`cargo --version` works) -- Internet access for fetching crates - -## Milestones - -- **M1**: Skeleton & domain types — project compiles with domain layer complete -- **M2**: Provider layer — Brave DTOs, mapper, and `BraveProvider` compile and can call Brave API -- **M3**: App & CLI — `SearchService`, CLI args, output rendering wired together -- **M4**: Tests & integration — basic tests pass and CLI runs end-to-end against Brave - -## Ticket Index - - - -| Task | Title | Ticket | -|---|---|---| -| T001 | Bootstrap Rust project and dependencies | [tickets/T001.md](tickets/T001.md) | -| T002 | Implement domain core types | [tickets/T002.md](tickets/T002.md) | -| T003 | Implement transport layer (HttpClient trait + reqwest adapter) | [tickets/T003.md](tickets/T003.md) | -| T004 | Implement SearchProvider trait and capabilities | [tickets/T004.md](tickets/T004.md) | -| T005 | Implement Brave DTOs | [tickets/T005.md](tickets/T005.md) | -| T006 | Implement Brave mapper | [tickets/T006.md](tickets/T006.md) | -| T007 | Implement BraveProvider | [tickets/T007.md](tickets/T007.md) | -| T008 | Implement SearchService | [tickets/T008.md](tickets/T008.md) | -| T009 | Implement CLI argument parsing | [tickets/T009.md](tickets/T009.md) | -| T010 | Implement output rendering | [tickets/T010.md](tickets/T010.md) | -| T011 | Wire main.rs and run end-to-end | [tickets/T011.md](tickets/T011.md) | -| T012 | Add boundary tests and CI-ready verification | [tickets/T012.md](tickets/T012.md) | - - - -## Work Breakdown (Tasks) - -### T001: Bootstrap Rust project and dependencies - -**Summary**: Initialize Cargo project and add all required dependencies to `Cargo.toml`. - -**Owner**: backend - -**Estimate**: 15m - -**Dependencies**: - -**Target milestone**: M1 - -**Acceptance test**: `cargo check` runs without errors on a bare `main.rs`. - -**Files/modules touched**: -- `Cargo.toml` -- `src/main.rs` - -**Steps**: -1. Run `cargo init --name search-cli` in the repo root. -2. Add dependencies to `Cargo.toml`: - ```toml - [dependencies] - tokio = { version = "1", features = ["full"] } - reqwest = { version = "0.12", features = ["json"] } - serde = { version = "1.0", features = ["derive"] } - serde_json = "1.0" - thiserror = "1.0" - async-trait = "0.1" - clap = { version = "4", features = ["derive"] } - dotenvy = "0.15" - ``` -3. Replace `src/main.rs` with a minimal `async fn main() {}`. -4. Run `cargo check` and confirm success. - ---- - -### T002: Implement domain core types - -**Summary**: Create all provider-agnostic domain types, enums, and the top-level error type. - -**Owner**: backend - -**Estimate**: 30m - -**Dependencies**: T001 - -**Target milestone**: M1 - -**Acceptance test**: `cargo check` passes with all domain modules compiling. - -**Files/modules touched**: -- `src/domain/mod.rs` -- `src/domain/types.rs` -- `src/domain/query.rs` -- `src/domain/result.rs` -- `src/domain/error.rs` - -**Steps**: -1. Create `src/domain/mod.rs` that re-exports all submodules: - ```rust - pub mod error; - pub mod query; - pub mod result; - pub mod types; - ``` -2. In `src/domain/types.rs`, define: - ```rust - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - pub enum SearchType { Web, News, Images, Videos } - - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - pub enum SafeSearch { Off, Moderate, Strict } - - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - pub enum TimeRange { Day, Week, Month, Year } - ``` -3. In `src/domain/query.rs`, define: - ```rust - use crate::domain::types::*; - - #[derive(Debug, Clone, PartialEq, Eq)] - pub struct SearchQuery { - pub text: String, - pub search_type: SearchType, - pub limit: Option, - pub offset: Option, - pub safe_search: Option, - pub country: Option, - pub language: Option, - pub time_range: Option, - } - ``` -4. In `src/domain/result.rs`, define: - ```rust - #[derive(Debug, Clone, PartialEq, Eq)] - pub struct PageToken(pub String); - - #[derive(Debug, Clone, PartialEq)] - pub struct SearchResponse { - pub query: String, - pub provider: String, - pub results: Vec, - pub total_estimated: Option, - pub next_page: Option, - } - - #[derive(Debug, Clone, PartialEq)] - pub enum SearchResult { - Web(WebResult), - News(NewsResult), - Image(ImageResult), - Video(VideoResult), - } - - #[derive(Debug, Clone, PartialEq)] - pub struct WebResult { - pub title: String, - pub url: String, - pub snippet: Option, - pub display_url: Option, - } - - #[derive(Debug, Clone, PartialEq)] - pub struct NewsResult { - pub title: String, - pub url: String, - pub snippet: Option, - pub source: Option, - pub published_at: Option, - } - - #[derive(Debug, Clone, PartialEq)] - pub struct ImageResult { - pub title: String, - pub url: String, - pub thumbnail_url: Option, - pub source: Option, - } - - #[derive(Debug, Clone, PartialEq)] - pub struct VideoResult { - pub title: String, - pub url: String, - pub thumbnail_url: Option, - pub duration: Option, - pub published_at: Option, - } - ``` - Note: use `String` for `provider` in `SearchResponse` to avoid circular dependency issues; we will use `"brave".to_string()`. -5. In `src/domain/error.rs`, define: - ```rust - #[derive(Debug, thiserror::Error)] - pub enum SearchError { - #[error("invalid query: {0}")] - InvalidQuery(String), - #[error("provider configuration error: {0}")] - Config(String), - #[error("authentication failed")] - Auth, - #[error("rate limited")] - RateLimited, - #[error("transport error: {0}")] - Transport(String), - #[error("provider returned invalid data: {0}")] - Decode(String), - #[error("provider error: {0}")] - Provider(String), - } - ``` -6. Run `cargo check` and fix any compilation errors. - ---- - -### T003: Implement transport layer (HttpClient trait + reqwest adapter) - -**Summary**: Define the `HttpClient` trait and a `reqwest`-based adapter so providers are decoupled from the HTTP library. - -**Owner**: backend - -**Estimate**: 25m - -**Dependencies**: T001, T002 - -**Target milestone**: M2 - -**Acceptance test**: `cargo check` compiles `transport/` and a simple `ReqwestHttpClient` struct exists. - -**Files/modules touched**: -- `src/transport/mod.rs` -- `src/transport/http.rs` - -**Steps**: -1. Create `src/transport/mod.rs` that declares `pub mod http;`. -2. In `src/transport/http.rs`, add: - ```rust - use async_trait::async_trait; - use crate::domain::error::SearchError; - - #[async_trait] - pub trait HttpClient: Send + Sync { - async fn get_json( - &self, - url: &str, - headers: Vec<(String, String)>, - query: Vec<(String, String)>, - ) -> Result - where - T: serde::de::DeserializeOwned + Send; - } - ``` -3. Implement `ReqwestHttpClient`: - ```rust - use reqwest::Client; - - pub struct ReqwestHttpClient { - client: Client, - } - - impl ReqwestHttpClient { - pub fn new() -> Self { - Self { client: Client::new() } - } - } - - #[async_trait] - impl HttpClient for ReqwestHttpClient { - async fn get_json( - &self, - url: &str, - headers: Vec<(String, String)>, - query: Vec<(String, String)>, - ) -> Result - where - T: serde::de::DeserializeOwned + Send, - { - let mut req = self.client.get(url); - for (k, v) in headers { - req = req.header(k, v); - } - req = req.query(&query); - let resp = req.send().await.map_err(|e| SearchError::Transport(e.to_string()))?; - - if resp.status() == 401 || resp.status() == 403 { - return Err(SearchError::Auth); - } - if resp.status() == 429 { - return Err(SearchError::RateLimited); - } - if !resp.status().is_success() { - let text = resp.text().await.unwrap_or_default(); - return Err(SearchError::Provider(format!("HTTP {}: {}", resp.status(), text))); - } - - resp.json::().await.map_err(|e| SearchError::Decode(e.to_string())) - } - } - ``` -4. Run `cargo check`. - ---- - -### T004: Implement SearchProvider trait and capabilities - -**Summary**: Define the provider trait and capabilities struct in the domain layer so the app layer depends on abstractions. - -**Owner**: backend - -**Estimate**: 20m - -**Dependencies**: T002 - -**Target milestone**: M2 - -**Acceptance test**: `cargo check` passes with `SearchProvider` trait and `ProviderCapabilities` defined. - -**Files/modules touched**: -- `src/domain/mod.rs` -- `src/domain/provider.rs` - -**Steps**: -1. Add `pub mod provider;` to `src/domain/mod.rs`. -2. In `src/domain/provider.rs`, define: - ```rust - use async_trait::async_trait; - use crate::domain::query::SearchQuery; - use crate::domain::result::SearchResponse; - use crate::domain::error::SearchError; - - #[derive(Debug, Clone)] - pub struct ProviderCapabilities { - pub web: bool, - pub news: bool, - pub images: bool, - pub videos: bool, - pub pagination: bool, - pub safe_search: bool, - pub time_range_filter: bool, - } - - #[async_trait] - pub trait SearchProvider: Send + Sync { - fn id(&self) -> String; - fn capabilities(&self) -> ProviderCapabilities; - async fn search(&self, query: &SearchQuery) -> Result; - } - ``` -3. Run `cargo check`. - ---- - -### T005: Implement Brave DTOs - -**Summary**: Create Brave-specific request/response structs to isolate provider JSON shapes from the domain. - -**Owner**: backend - -**Estimate**: 25m - -**Dependencies**: T001, T002 - -**Target milestone**: M2 - -**Acceptance test**: `cargo check` compiles `providers/brave/dto.rs` with all structs deriving `Deserialize`. - -**Files/modules touched**: -- `src/providers/mod.rs` -- `src/providers/brave/mod.rs` -- `src/providers/brave/dto.rs` - -**Steps**: -1. Create `src/providers/mod.rs` with `pub mod brave;`. -2. Create `src/providers/brave/mod.rs` that declares: - ```rust - pub mod client; - pub mod config; - pub mod dto; - pub mod mapper; - ``` -3. In `src/providers/brave/dto.rs`, define the following `Deserialize` structs based on Brave API v1 response shapes: - ```rust - use serde::Deserialize; - - #[derive(Debug, Deserialize)] - pub struct BraveWebResponse { - pub query: Option, - pub web: Option, - } - - #[derive(Debug, Deserialize)] - pub struct BraveQuery { - pub original: Option, - } - - #[derive(Debug, Deserialize)] - pub struct BraveWebResults { - pub results: Option>, - pub total: Option, - } - - #[derive(Debug, Deserialize)] - pub struct BraveWebResult { - pub title: Option, - pub url: Option, - pub description: Option, - pub display_url: Option, - } - - // News - #[derive(Debug, Deserialize)] - pub struct BraveNewsResponse { - pub query: Option, - pub news: Option, - } - - #[derive(Debug, Deserialize)] - pub struct BraveNewsResults { - pub results: Option>, - } - - #[derive(Debug, Deserialize)] - pub struct BraveNewsResult { - pub title: Option, - pub url: Option, - pub description: Option, - pub source: Option, - pub age: Option, - } - - // Images - #[derive(Debug, Deserialize)] - pub struct BraveImagesResponse { - pub query: Option, - pub image_results: Option>, - } - - #[derive(Debug, Deserialize)] - pub struct BraveImageResult { - pub title: Option, - pub url: Option, - pub thumbnail: Option, - pub source: Option, - } - - #[derive(Debug, Deserialize)] - pub struct BraveThumbnail { - pub src: Option, - } - - // Videos - #[derive(Debug, Deserialize)] - pub struct BraveVideosResponse { - pub query: Option, - pub videos: Option, - } - - #[derive(Debug, Deserialize)] - pub struct BraveVideosResults { - pub results: Option>, - } - - #[derive(Debug, Deserialize)] - pub struct BraveVideoResult { - pub title: Option, - pub url: Option, - pub thumbnail: Option, - pub duration: Option, - pub age: Option, - } - ``` -4. Run `cargo check`. - ---- - -### T006: Implement Brave mapper - -**Summary**: Write mapper functions that convert Brave DTOs into domain `SearchResponse` and `SearchResult` enums. - -**Owner**: backend - -**Estimate**: 30m - -**Dependencies**: T004, T005 - -**Target milestone**: M2 - -**Acceptance test**: Unit tests in `mapper.rs` verify that sample Brave DTOs map to correct domain results. - -**Files/modules touched**: -- `src/providers/brave/mapper.rs` - -**Steps**: -1. In `src/providers/brave/mapper.rs`, implement four mapper functions: - ```rust - use crate::domain::result::*; - use crate::providers::brave::dto::*; - - pub fn map_web_response(dto: BraveWebResponse) -> SearchResponse { - let query_text = dto.query.and_then(|q| q.original).unwrap_or_default(); - let results = dto.web.and_then(|w| w.results).unwrap_or_default(); - SearchResponse { - query: query_text, - provider: "brave".to_string(), - total_estimated: dto.web.as_ref().and_then(|w| w.total), - next_page: None, - results: results.into_iter().map(|r| { - SearchResult::Web(WebResult { - title: r.title.unwrap_or_default(), - url: r.url.unwrap_or_default(), - snippet: r.description, - display_url: r.display_url, - }) - }).collect(), - } - } - ``` -2. Implement `map_news_response`, `map_images_response`, `map_videos_response` following the same pattern, mapping to `SearchResult::News`, `SearchResult::Image`, and `SearchResult::Video` respectively. -3. Add `#[cfg(test)]` module with basic unit tests that construct minimal DTOs and assert the mapped domain output. -4. Run `cargo test --lib` for the mapper tests. - ---- - -### T007: Implement BraveProvider - -**Summary**: Build the concrete `BraveProvider` that implements `SearchProvider`, dispatches by `SearchType`, and calls the Brave API. - -**Owner**: backend - -**Estimate**: 45m - -**Dependencies**: T003, T004, T005, T006 - -**Target milestone**: M2 - -**Acceptance test**: A simple integration test (or manual `cargo run`) performs a web search via Brave and prints results. For automated acceptance, mock the `HttpClient` trait to return a sample JSON string and verify `BraveProvider` returns a `SearchResponse`. - -**Files/modules touched**: -- `src/providers/brave/client.rs` -- `src/providers/brave/config.rs` - -**Steps**: -1. In `src/providers/brave/config.rs`, define: - ```rust - #[derive(Debug, Clone)] - pub struct BraveConfig { - pub api_key: String, - pub base_url: String, - } - - impl BraveConfig { - pub fn from_env() -> Result { - let api_key = std::env::var("BRAVE_API_KEY")?; - Ok(Self { - api_key, - base_url: "https://api.search.brave.com/res/v1".to_string(), - }) - } - } - ``` -2. In `src/providers/brave/client.rs`, define: - ```rust - use async_trait::async_trait; - use crate::domain::error::SearchError; - use crate::domain::provider::{ProviderCapabilities, SearchProvider}; - use crate::domain::query::SearchQuery; - use crate::domain::result::SearchResponse; - use crate::domain::types::SearchType; - use crate::transport::http::HttpClient; - use crate::providers::brave::config::BraveConfig; - use crate::providers::brave::dto::*; - use crate::providers::brave::mapper::*; - - pub struct BraveProvider { - client: C, - config: BraveConfig, - } - - impl BraveProvider { - pub fn new(client: C, config: BraveConfig) -> Self { - Self { client, config } - } - } - - #[async_trait] - impl SearchProvider for BraveProvider { - fn id(&self) -> String { - "brave".to_string() - } - - fn capabilities(&self) -> ProviderCapabilities { - ProviderCapabilities { - web: true, - news: true, - images: true, - videos: true, - pagination: false, - safe_search: true, - time_range_filter: true, - } - } - - async fn search(&self, query: &SearchQuery) -> Result { - let endpoint = match query.search_type { - SearchType::Web => "web/search", - SearchType::News => "news/search", - SearchType::Images => "images/search", - SearchType::Videos => "videos/search", - }; - let url = format!("{}/{}", self.config.base_url, endpoint); - - let mut params: Vec<(String, String)> = vec![ - ("q".to_string(), query.text.clone()), - ]; - if let Some(limit) = query.limit { - params.push(("count".to_string(), limit.to_string())); - } - if let Some(offset) = query.offset { - params.push(("offset".to_string(), offset.to_string())); - } - if let Some(ss) = query.safe_search { - let val = match ss { - crate::domain::types::SafeSearch::Off => "off", - crate::domain::types::SafeSearch::Moderate => "moderate", - crate::domain::types::SafeSearch::Strict => "strict", - }; - params.push(("safesearch".to_string(), val.to_string())); - } - if let Some(ref country) = query.country { - params.push(("country".to_string(), country.clone())); - } - if let Some(ref lang) = query.language { - params.push(("search_lang".to_string(), lang.clone())); - } - if let Some(ref tr) = query.time_range { - let val = match tr { - crate::domain::types::TimeRange::Day => "day", - crate::domain::types::TimeRange::Week => "week", - crate::domain::types::TimeRange::Month => "month", - crate::domain::types::TimeRange::Year => "year", - }; - params.push(("freshness".to_string(), val.to_string())); - } - - let headers = vec![ - ("Accept".to_string(), "application/json".to_string()), - ("X-Subscription-Token".to_string(), self.config.api_key.clone()), - ]; - - match query.search_type { - SearchType::Web => { - let dto: BraveWebResponse = self.client.get_json(&url, headers, params).await?; - Ok(map_web_response(dto)) - } - SearchType::News => { - let dto: BraveNewsResponse = self.client.get_json(&url, headers, params).await?; - Ok(map_news_response(dto)) - } - SearchType::Images => { - let dto: BraveImagesResponse = self.client.get_json(&url, headers, params).await?; - Ok(map_images_response(dto)) - } - SearchType::Videos => { - let dto: BraveVideosResponse = self.client.get_json(&url, headers, params).await?; - Ok(map_videos_response(dto)) - } - } - } - } - ``` -3. Add a `#[cfg(test)]` mock `HttpClient` that returns a hardcoded JSON string and assert `BraveProvider::search` produces the expected `SearchResponse`. -4. Run `cargo test --lib`. - ---- - -### T008: Implement SearchService - -**Summary**: Build the application-layer `SearchService` that accepts a `dyn SearchProvider` and orchestrates searches. - -**Owner**: backend - -**Estimate**: 20m - -**Dependencies**: T004, T007 - -**Target milestone**: M3 - -**Acceptance test**: `cargo check` compiles `SearchService` and a test proves it delegates to a mocked `SearchProvider`. - -**Files/modules touched**: -- `src/app/mod.rs` -- `src/app/search_service.rs` - -**Steps**: -1. Create `src/app/mod.rs` with `pub mod search_service;`. -2. In `src/app/search_service.rs`, define: - ```rust - use crate::domain::error::SearchError; - use crate::domain::provider::SearchProvider; - use crate::domain::query::SearchQuery; - use crate::domain::result::SearchResponse; - - pub struct SearchService { - provider: Box, - } - - impl SearchService { - pub fn new(provider: Box) -> Self { - Self { provider } - } - - pub async fn search(&self, query: SearchQuery) -> Result { - self.provider.search(&query).await - } - } - ``` -3. Add a test that creates a mock `SearchProvider` (implement the trait on a simple struct) and verify `SearchService::search` returns the mock response. -4. Run `cargo test --lib`. - ---- - -### T009: Implement CLI argument parsing - -**Summary**: Use `clap` to parse user input into a `SearchCommand` struct with query, search type, limit, and output format. - -**Owner**: backend - -**Estimate**: 25m - -**Dependencies**: T001, T002 - -**Target milestone**: M3 - -**Acceptance test**: `cargo run -- --help` displays all options and `cargo run -- "rust"` defaults to web search. - -**Files/modules touched**: -- `src/cli/mod.rs` -- `src/cli/args.rs` - -**Steps**: -1. Create `src/cli/mod.rs` with: - ```rust - pub mod args; - pub mod output; - ``` -2. In `src/cli/args.rs`, define: - ```rust - use clap::{Parser, ValueEnum}; - use crate::domain::types::SearchType; - - #[derive(Debug, Clone, ValueEnum)] - pub enum CliSearchType { - Web, - News, - Images, - Videos, - } - - impl From for SearchType { - fn from(val: CliSearchType) -> Self { - match val { - CliSearchType::Web => SearchType::Web, - CliSearchType::News => SearchType::News, - CliSearchType::Images => SearchType::Images, - CliSearchType::Videos => SearchType::Videos, - } - } - } - - #[derive(Parser, Debug)] - #[command(name = "search-cli")] - #[command(about = "Provider-agnostic search CLI")] - pub struct CliArgs { - #[arg(help = "Search query text")] - pub query: String, - - #[arg(short, long, value_enum, default_value = "web")] - pub search_type: CliSearchType, - - #[arg(short, long)] - pub limit: Option, - - #[arg(long)] - pub offset: Option, - - #[arg(long, value_enum)] - pub safe_search: Option, - - #[arg(long)] - pub country: Option, - - #[arg(long)] - pub language: Option, - } - ``` - Note: `SafeSearch` already derives the necessary traits, but `clap::ValueEnum` requires additional derive. If compilation fails, add a manual mapping instead of using `value_enum` on `SafeSearch`. Create a `CliSafeSearch` enum and map it if needed. -3. Update `src/main.rs` to parse args: - ```rust - use clap::Parser; - use search_cli::cli::args::CliArgs; - - #[tokio::main] - async fn main() { - let _args = CliArgs::parse(); - println!("{:?}", _args); - } - ``` - (Temporarily expose `cli` module from a lib or keep everything in `main.rs` if the project is bin-only.) - - **Important**: Since this is a binary crate, either make `main.rs` contain the modules directly, or create `src/lib.rs`. To keep it simple, declare modules in `main.rs`: - ```rust - mod app; - mod cli; - mod domain; - mod providers; - mod transport; - ``` - Update `main.rs` to include these mod declarations and parse args. -4. Run `cargo run -- --help` and confirm output. -5. Run `cargo run -- "rust"` and confirm it prints the parsed args. - ---- - -### T010: Implement output rendering - -**Summary**: Create a simple text renderer that prints `SearchResponse` results in a human-readable format. - -**Owner**: backend - -**Estimate**: 20m - -**Dependencies**: T002, T009 - -**Target milestone**: M3 - -**Acceptance test**: A test provides a `SearchResponse` and the renderer returns a string containing all result titles and URLs. - -**Files/modules touched**: -- `src/cli/output.rs` - -**Steps**: -1. In `src/cli/output.rs`, define: - ```rust - use crate::domain::result::{SearchResponse, SearchResult}; - - pub fn render_text(response: &SearchResponse) -> String { - let mut lines = vec![ - format!("Provider: {}", response.provider), - format!("Query: {}", response.query), - format!("Results: {}", response.results.len()), - String::new(), - ]; - for (i, result) in response.results.iter().enumerate() { - match result { - SearchResult::Web(r) => { - lines.push(format!("{}. [{}]", i + 1, r.title)); - lines.push(format!(" URL: {}", r.url)); - if let Some(s) = &r.snippet { - lines.push(format!(" {}", s)); - } - } - SearchResult::News(r) => { - lines.push(format!("{}. [NEWS] {}", i + 1, r.title)); - lines.push(format!(" URL: {}", r.url)); - if let Some(s) = &r.source { - lines.push(format!(" Source: {}", s)); - } - } - SearchResult::Image(r) => { - lines.push(format!("{}. [IMAGE] {}", i + 1, r.title)); - lines.push(format!(" URL: {}", r.url)); - } - SearchResult::Video(r) => { - lines.push(format!("{}. [VIDEO] {}", i + 1, r.title)); - lines.push(format!(" URL: {}", r.url)); - } - } - lines.push(String::new()); - } - lines.join("\n") - } - ``` -2. Add a unit test that constructs a `SearchResponse` with mixed results and asserts the rendered string contains expected substrings. -3. Run `cargo test --lib`. - ---- - -### T011: Wire main.rs and run end-to-end - -**Summary**: Connect CLI parsing, `SearchService`, `BraveProvider`, and output rendering in `main.rs` so the CLI performs a live search. - -**Owner**: backend - -**Estimate**: 25m - -**Dependencies**: T007, T008, T009, T010 - -**Target milestone**: M4 - -**Acceptance test**: `cargo run -- "rust programming"` successfully queries Brave and prints at least one search result. If API key is missing, it prints a clean error message. - -**Files/modules touched**: -- `src/main.rs` - -**Steps**: -1. In `src/main.rs`, replace contents with: - ```rust - mod app; - mod cli; - mod domain; - mod providers; - mod transport; - - use clap::Parser; - use cli::args::CliArgs; - use cli::output::render_text; - use app::search_service::SearchService; - use domain::query::SearchQuery; - use domain::types::SafeSearch; - use providers::brave::client::BraveProvider; - use providers::brave::config::BraveConfig; - use transport::http::ReqwestHttpClient; - - #[tokio::main] - async fn main() { - dotenvy::dotenv().ok(); - - let args = CliArgs::parse(); - - let config = match BraveConfig::from_env() { - Ok(c) => c, - Err(e) => { - eprintln!("Failed to load Brave config: {}", e); - std::process::exit(1); - } - }; - - let client = ReqwestHttpClient::new(); - let provider = BraveProvider::new(client, config); - let service = SearchService::new(Box::new(provider)); - - let query = SearchQuery { - text: args.query, - search_type: args.search_type.into(), - limit: args.limit, - offset: args.offset, - safe_search: args.safe_search, - country: args.country, - language: args.language, - time_range: None, - }; - - match service.search(query).await { - Ok(response) => { - println!("{}", render_text(&response)); - } - Err(e) => { - eprintln!("Search failed: {}", e); - std::process::exit(1); - } - } - } - ``` - Note: If `SafeSearch` does not implement `clap::ValueEnum`, adjust `CliArgs` to use a local `CliSafeSearch` enum and map it in the `SearchQuery` construction. -2. Ensure `cargo check` passes. -3. Run `cargo run -- "rust programming"` and verify it returns search results. -4. Run `cargo run -- "rust" --search-type news` and verify news results. -5. Run `cargo run -- "cats" --search-type images --limit 3` and verify image results. - ---- - -### T012: Add boundary tests and CI-ready verification - -**Summary**: Add integration-style tests at the app and provider boundaries, and ensure `cargo test` passes cleanly. - -**Owner**: backend - -**Estimate**: 30m - -**Dependencies**: T011 - -**Target milestone**: M4 - -**Acceptance test**: `cargo test` passes with at least one test for mapper, one for `BraveProvider` with mock HTTP, and one for `SearchService` with mock provider. - -**Files/modules touched**: -- `src/providers/brave/mapper.rs` -- `src/providers/brave/client.rs` -- `src/app/search_service.rs` - -**Steps**: -1. In `src/providers/brave/mapper.rs`, ensure the existing `#[cfg(test)]` module covers all four search types. -2. In `src/providers/brave/client.rs`, ensure the mock HTTP test validates that headers include `X-Subscription-Token` and that query params include `q`, `count`, and `safesearch`. -3. In `src/app/search_service.rs`, ensure the mock provider test validates delegation. -4. Run `cargo test` and fix any failures. -5. Run `cargo clippy` (if available) and fix warnings. - -## Risks & Mitigations - -| Risk | Mitigation | -|------|------------| -| Brave API schema differs from documented DTOs | Start with minimal fields; mapper defaults `Option` values to empty strings rather than failing | -| `clap` `ValueEnum` incompatibility with domain enums | Use separate CLI enums and map them explicitly to domain types | -| Rate limiting during manual testing | Keep query counts low; use `limit` flag; mock HTTP for automated tests | -| Missing env var in CI/test environments | `BraveConfig::from_env()` returns clear error; tests use mock config | - -## Test Strategy - -- **Mapper tests**: Construct minimal DTOs, assert domain output (T006). -- **Provider tests**: Mock `HttpClient` to return JSON, assert `BraveProvider` produces `SearchResponse` (T007). -- **Service tests**: Mock `SearchProvider`, assert `SearchService` delegates correctly (T008). -- **Renderer tests**: Provide `SearchResponse`, assert string output contains expected data (T010). -- **E2E manual**: Run CLI with live Brave API for web, news, images, and video queries (T011). - -## References - -- PRD.md sections 1–10 (domain design, provider trait, Brave mapping, module layout) -- `Cargo.toml` dependency versions aligned with PRD async/HTTP stack -- Brave Search API docs: https://api-dashboard.search.brave.com/documentation - -## Final Gate - -- **Output summary**: plan dir `.artifacts/plan/2026-04-14_search-cli/`, 4 milestones, 12 tickets -- **Next step**: proceed to execute-phase with `.artifacts/plan/2026-04-14_search-cli/PLAN.md` diff --git a/.artifacts/plan/2026-04-14_search-cli/tickets/INDEX.md b/.artifacts/plan/2026-04-14_search-cli/tickets/INDEX.md deleted file mode 100644 index 1ab5c79..0000000 --- a/.artifacts/plan/2026-04-14_search-cli/tickets/INDEX.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -title: "Ticket Index" -type: ticket_index -parent_plan: "../PLAN.md" -created_at: "2026-04-14T17:44:55Z" -tags: [ticket, plan] ---- - -# Ticket Index - -| Task | Title | Ticket | -|---|---|---| -| T001 | Bootstrap Rust project and dependencies | [T001](./T001.md) | -| T002 | Implement domain core types | [T002](./T002.md) | -| T003 | Implement transport layer (HttpClient trait + reqwest adapter) | [T003](./T003.md) | -| T004 | Implement SearchProvider trait and capabilities | [T004](./T004.md) | -| T005 | Implement Brave DTOs | [T005](./T005.md) | -| T006 | Implement Brave mapper | [T006](./T006.md) | -| T007 | Implement BraveProvider | [T007](./T007.md) | -| T008 | Implement SearchService | [T008](./T008.md) | -| T009 | Implement CLI argument parsing | [T009](./T009.md) | -| T010 | Implement output rendering | [T010](./T010.md) | -| T011 | Wire main.rs and run end-to-end | [T011](./T011.md) | -| T012 | Add boundary tests and CI-ready verification | [T012](./T012.md) | diff --git a/.artifacts/plan/2026-04-14_search-cli/tickets/T001.md b/.artifacts/plan/2026-04-14_search-cli/tickets/T001.md deleted file mode 100644 index c052e60..0000000 --- a/.artifacts/plan/2026-04-14_search-cli/tickets/T001.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -title: "T001: Bootstrap Rust project and dependencies" -type: plan_ticket -task_id: "T001" -parent_plan: "../PLAN.md" -created_at: "2026-04-14T17:44:55Z" -tags: [ticket, plan] ---- - -# T001: Bootstrap Rust project and dependencies - -**Summary**: Initialize Cargo project and add all required dependencies to `Cargo.toml`. - -**Owner**: backend - -**Estimate**: 15m - -**Dependencies**: - -**Target milestone**: M1 - -**Acceptance test**: `cargo check` runs without errors on a bare `main.rs`. - -**Files/modules touched**: -- `Cargo.toml` -- `src/main.rs` - -**Steps**: -1. Run `cargo init --name search-cli` in the repo root. -2. Add dependencies to `Cargo.toml`: - ```toml - [dependencies] - tokio = { version = "1", features = ["full"] } - reqwest = { version = "0.12", features = ["json"] } - serde = { version = "1.0", features = ["derive"] } - serde_json = "1.0" - thiserror = "1.0" - async-trait = "0.1" - clap = { version = "4", features = ["derive"] } - dotenvy = "0.15" - ``` -3. Replace `src/main.rs` with a minimal `async fn main() {}`. -4. Run `cargo check` and confirm success. - ---- diff --git a/.artifacts/plan/2026-04-14_search-cli/tickets/T002.md b/.artifacts/plan/2026-04-14_search-cli/tickets/T002.md deleted file mode 100644 index 87157ac..0000000 --- a/.artifacts/plan/2026-04-14_search-cli/tickets/T002.md +++ /dev/null @@ -1,145 +0,0 @@ ---- -title: "T002: Implement domain core types" -type: plan_ticket -task_id: "T002" -parent_plan: "../PLAN.md" -created_at: "2026-04-14T17:44:55Z" -tags: [ticket, plan] ---- - -# T002: Implement domain core types - -**Summary**: Create all provider-agnostic domain types, enums, and the top-level error type. - -**Owner**: backend - -**Estimate**: 30m - -**Dependencies**: T001 - -**Target milestone**: M1 - -**Acceptance test**: `cargo check` passes with all domain modules compiling. - -**Files/modules touched**: -- `src/domain/mod.rs` -- `src/domain/types.rs` -- `src/domain/query.rs` -- `src/domain/result.rs` -- `src/domain/error.rs` - -**Steps**: -1. Create `src/domain/mod.rs` that re-exports all submodules: - ```rust - pub mod error; - pub mod query; - pub mod result; - pub mod types; - ``` -2. In `src/domain/types.rs`, define: - ```rust - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - pub enum SearchType { Web, News, Images, Videos } - - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - pub enum SafeSearch { Off, Moderate, Strict } - - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - pub enum TimeRange { Day, Week, Month, Year } - ``` -3. In `src/domain/query.rs`, define: - ```rust - use crate::domain::types::*; - - #[derive(Debug, Clone, PartialEq, Eq)] - pub struct SearchQuery { - pub text: String, - pub search_type: SearchType, - pub limit: Option, - pub offset: Option, - pub safe_search: Option, - pub country: Option, - pub language: Option, - pub time_range: Option, - } - ``` -4. In `src/domain/result.rs`, define: - ```rust - #[derive(Debug, Clone, PartialEq, Eq)] - pub struct PageToken(pub String); - - #[derive(Debug, Clone, PartialEq)] - pub struct SearchResponse { - pub query: String, - pub provider: String, - pub results: Vec, - pub total_estimated: Option, - pub next_page: Option, - } - - #[derive(Debug, Clone, PartialEq)] - pub enum SearchResult { - Web(WebResult), - News(NewsResult), - Image(ImageResult), - Video(VideoResult), - } - - #[derive(Debug, Clone, PartialEq)] - pub struct WebResult { - pub title: String, - pub url: String, - pub snippet: Option, - pub display_url: Option, - } - - #[derive(Debug, Clone, PartialEq)] - pub struct NewsResult { - pub title: String, - pub url: String, - pub snippet: Option, - pub source: Option, - pub published_at: Option, - } - - #[derive(Debug, Clone, PartialEq)] - pub struct ImageResult { - pub title: String, - pub url: String, - pub thumbnail_url: Option, - pub source: Option, - } - - #[derive(Debug, Clone, PartialEq)] - pub struct VideoResult { - pub title: String, - pub url: String, - pub thumbnail_url: Option, - pub duration: Option, - pub published_at: Option, - } - ``` - Note: use `String` for `provider` in `SearchResponse` to avoid circular dependency issues; we will use `"brave".to_string()`. -5. In `src/domain/error.rs`, define: - ```rust - #[derive(Debug, thiserror::Error)] - pub enum SearchError { - #[error("invalid query: {0}")] - InvalidQuery(String), - #[error("provider configuration error: {0}")] - Config(String), - #[error("authentication failed")] - Auth, - #[error("rate limited")] - RateLimited, - #[error("transport error: {0}")] - Transport(String), - #[error("provider returned invalid data: {0}")] - Decode(String), - #[error("provider error: {0}")] - Provider(String), - } - ``` -6. Run `cargo check` and fix any compilation errors. - ---- diff --git a/.artifacts/plan/2026-04-14_search-cli/tickets/T003.md b/.artifacts/plan/2026-04-14_search-cli/tickets/T003.md deleted file mode 100644 index 059d404..0000000 --- a/.artifacts/plan/2026-04-14_search-cli/tickets/T003.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -title: "T003: Implement transport layer (HttpClient trait + reqwest adapter)" -type: plan_ticket -task_id: "T003" -parent_plan: "../PLAN.md" -created_at: "2026-04-14T17:44:55Z" -tags: [ticket, plan] ---- - -# T003: Implement transport layer (HttpClient trait + reqwest adapter) - -**Summary**: Define the `HttpClient` trait and a `reqwest`-based adapter so providers are decoupled from the HTTP library. - -**Owner**: backend - -**Estimate**: 25m - -**Dependencies**: T001, T002 - -**Target milestone**: M2 - -**Acceptance test**: `cargo check` compiles `transport/` and a simple `ReqwestHttpClient` struct exists. - -**Files/modules touched**: -- `src/transport/mod.rs` -- `src/transport/http.rs` - -**Steps**: -1. Create `src/transport/mod.rs` that declares `pub mod http;`. -2. In `src/transport/http.rs`, add: - ```rust - use async_trait::async_trait; - use crate::domain::error::SearchError; - - #[async_trait] - pub trait HttpClient: Send + Sync { - async fn get_json( - &self, - url: &str, - headers: Vec<(String, String)>, - query: Vec<(String, String)>, - ) -> Result - where - T: serde::de::DeserializeOwned + Send; - } - ``` -3. Implement `ReqwestHttpClient`: - ```rust - use reqwest::Client; - - pub struct ReqwestHttpClient { - client: Client, - } - - impl ReqwestHttpClient { - pub fn new() -> Self { - Self { client: Client::new() } - } - } - - #[async_trait] - impl HttpClient for ReqwestHttpClient { - async fn get_json( - &self, - url: &str, - headers: Vec<(String, String)>, - query: Vec<(String, String)>, - ) -> Result - where - T: serde::de::DeserializeOwned + Send, - { - let mut req = self.client.get(url); - for (k, v) in headers { - req = req.header(k, v); - } - req = req.query(&query); - let resp = req.send().await.map_err(|e| SearchError::Transport(e.to_string()))?; - - if resp.status() == 401 || resp.status() == 403 { - return Err(SearchError::Auth); - } - if resp.status() == 429 { - return Err(SearchError::RateLimited); - } - if !resp.status().is_success() { - let text = resp.text().await.unwrap_or_default(); - return Err(SearchError::Provider(format!("HTTP {}: {}", resp.status(), text))); - } - - resp.json::().await.map_err(|e| SearchError::Decode(e.to_string())) - } - } - ``` -4. Run `cargo check`. - ---- diff --git a/.artifacts/plan/2026-04-14_search-cli/tickets/T004.md b/.artifacts/plan/2026-04-14_search-cli/tickets/T004.md deleted file mode 100644 index 2958375..0000000 --- a/.artifacts/plan/2026-04-14_search-cli/tickets/T004.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -title: "T004: Implement SearchProvider trait and capabilities" -type: plan_ticket -task_id: "T004" -parent_plan: "../PLAN.md" -created_at: "2026-04-14T17:44:55Z" -tags: [ticket, plan] ---- - -# T004: Implement SearchProvider trait and capabilities - -**Summary**: Define the provider trait and capabilities struct in the domain layer so the app layer depends on abstractions. - -**Owner**: backend - -**Estimate**: 20m - -**Dependencies**: T002 - -**Target milestone**: M2 - -**Acceptance test**: `cargo check` passes with `SearchProvider` trait and `ProviderCapabilities` defined. - -**Files/modules touched**: -- `src/domain/mod.rs` -- `src/domain/provider.rs` - -**Steps**: -1. Add `pub mod provider;` to `src/domain/mod.rs`. -2. In `src/domain/provider.rs`, define: - ```rust - use async_trait::async_trait; - use crate::domain::query::SearchQuery; - use crate::domain::result::SearchResponse; - use crate::domain::error::SearchError; - - #[derive(Debug, Clone)] - pub struct ProviderCapabilities { - pub web: bool, - pub news: bool, - pub images: bool, - pub videos: bool, - pub pagination: bool, - pub safe_search: bool, - pub time_range_filter: bool, - } - - #[async_trait] - pub trait SearchProvider: Send + Sync { - fn id(&self) -> String; - fn capabilities(&self) -> ProviderCapabilities; - async fn search(&self, query: &SearchQuery) -> Result; - } - ``` -3. Run `cargo check`. - ---- diff --git a/.artifacts/plan/2026-04-14_search-cli/tickets/T005.md b/.artifacts/plan/2026-04-14_search-cli/tickets/T005.md deleted file mode 100644 index f0ba742..0000000 --- a/.artifacts/plan/2026-04-14_search-cli/tickets/T005.md +++ /dev/null @@ -1,131 +0,0 @@ ---- -title: "T005: Implement Brave DTOs" -type: plan_ticket -task_id: "T005" -parent_plan: "../PLAN.md" -created_at: "2026-04-14T17:44:55Z" -tags: [ticket, plan] ---- - -# T005: Implement Brave DTOs - -**Summary**: Create Brave-specific request/response structs to isolate provider JSON shapes from the domain. - -**Owner**: backend - -**Estimate**: 25m - -**Dependencies**: T001, T002 - -**Target milestone**: M2 - -**Acceptance test**: `cargo check` compiles `providers/brave/dto.rs` with all structs deriving `Deserialize`. - -**Files/modules touched**: -- `src/providers/mod.rs` -- `src/providers/brave/mod.rs` -- `src/providers/brave/dto.rs` - -**Steps**: -1. Create `src/providers/mod.rs` with `pub mod brave;`. -2. Create `src/providers/brave/mod.rs` that declares: - ```rust - pub mod client; - pub mod config; - pub mod dto; - pub mod mapper; - ``` -3. In `src/providers/brave/dto.rs`, define the following `Deserialize` structs based on Brave API v1 response shapes: - ```rust - use serde::Deserialize; - - #[derive(Debug, Deserialize)] - pub struct BraveWebResponse { - pub query: Option, - pub web: Option, - } - - #[derive(Debug, Deserialize)] - pub struct BraveQuery { - pub original: Option, - } - - #[derive(Debug, Deserialize)] - pub struct BraveWebResults { - pub results: Option>, - pub total: Option, - } - - #[derive(Debug, Deserialize)] - pub struct BraveWebResult { - pub title: Option, - pub url: Option, - pub description: Option, - pub display_url: Option, - } - - // News - #[derive(Debug, Deserialize)] - pub struct BraveNewsResponse { - pub query: Option, - pub news: Option, - } - - #[derive(Debug, Deserialize)] - pub struct BraveNewsResults { - pub results: Option>, - } - - #[derive(Debug, Deserialize)] - pub struct BraveNewsResult { - pub title: Option, - pub url: Option, - pub description: Option, - pub source: Option, - pub age: Option, - } - - // Images - #[derive(Debug, Deserialize)] - pub struct BraveImagesResponse { - pub query: Option, - pub image_results: Option>, - } - - #[derive(Debug, Deserialize)] - pub struct BraveImageResult { - pub title: Option, - pub url: Option, - pub thumbnail: Option, - pub source: Option, - } - - #[derive(Debug, Deserialize)] - pub struct BraveThumbnail { - pub src: Option, - } - - // Videos - #[derive(Debug, Deserialize)] - pub struct BraveVideosResponse { - pub query: Option, - pub videos: Option, - } - - #[derive(Debug, Deserialize)] - pub struct BraveVideosResults { - pub results: Option>, - } - - #[derive(Debug, Deserialize)] - pub struct BraveVideoResult { - pub title: Option, - pub url: Option, - pub thumbnail: Option, - pub duration: Option, - pub age: Option, - } - ``` -4. Run `cargo check`. - ---- diff --git a/.artifacts/plan/2026-04-14_search-cli/tickets/T006.md b/.artifacts/plan/2026-04-14_search-cli/tickets/T006.md deleted file mode 100644 index 8506614..0000000 --- a/.artifacts/plan/2026-04-14_search-cli/tickets/T006.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -title: "T006: Implement Brave mapper" -type: plan_ticket -task_id: "T006" -parent_plan: "../PLAN.md" -created_at: "2026-04-14T17:44:55Z" -tags: [ticket, plan] ---- - -# T006: Implement Brave mapper - -**Summary**: Write mapper functions that convert Brave DTOs into domain `SearchResponse` and `SearchResult` enums. - -**Owner**: backend - -**Estimate**: 30m - -**Dependencies**: T004, T005 - -**Target milestone**: M2 - -**Acceptance test**: Unit tests in `mapper.rs` verify that sample Brave DTOs map to correct domain results. - -**Files/modules touched**: -- `src/providers/brave/mapper.rs` - -**Steps**: -1. In `src/providers/brave/mapper.rs`, implement four mapper functions: - ```rust - use crate::domain::result::*; - use crate::providers::brave::dto::*; - - pub fn map_web_response(dto: BraveWebResponse) -> SearchResponse { - let query_text = dto.query.and_then(|q| q.original).unwrap_or_default(); - let results = dto.web.and_then(|w| w.results).unwrap_or_default(); - SearchResponse { - query: query_text, - provider: "brave".to_string(), - total_estimated: dto.web.as_ref().and_then(|w| w.total), - next_page: None, - results: results.into_iter().map(|r| { - SearchResult::Web(WebResult { - title: r.title.unwrap_or_default(), - url: r.url.unwrap_or_default(), - snippet: r.description, - display_url: r.display_url, - }) - }).collect(), - } - } - ``` -2. Implement `map_news_response`, `map_images_response`, `map_videos_response` following the same pattern, mapping to `SearchResult::News`, `SearchResult::Image`, and `SearchResult::Video` respectively. -3. Add `#[cfg(test)]` module with basic unit tests that construct minimal DTOs and assert the mapped domain output. -4. Run `cargo test --lib` for the mapper tests. - ---- diff --git a/.artifacts/plan/2026-04-14_search-cli/tickets/T007.md b/.artifacts/plan/2026-04-14_search-cli/tickets/T007.md deleted file mode 100644 index 622d2a3..0000000 --- a/.artifacts/plan/2026-04-14_search-cli/tickets/T007.md +++ /dev/null @@ -1,160 +0,0 @@ ---- -title: "T007: Implement BraveProvider" -type: plan_ticket -task_id: "T007" -parent_plan: "../PLAN.md" -created_at: "2026-04-14T17:44:55Z" -tags: [ticket, plan] ---- - -# T007: Implement BraveProvider - -**Summary**: Build the concrete `BraveProvider` that implements `SearchProvider`, dispatches by `SearchType`, and calls the Brave API. - -**Owner**: backend - -**Estimate**: 45m - -**Dependencies**: T003, T004, T005, T006 - -**Target milestone**: M2 - -**Acceptance test**: A simple integration test (or manual `cargo run`) performs a web search via Brave and prints results. For automated acceptance, mock the `HttpClient` trait to return a sample JSON string and verify `BraveProvider` returns a `SearchResponse`. - -**Files/modules touched**: -- `src/providers/brave/client.rs` -- `src/providers/brave/config.rs` - -**Steps**: -1. In `src/providers/brave/config.rs`, define: - ```rust - #[derive(Debug, Clone)] - pub struct BraveConfig { - pub api_key: String, - pub base_url: String, - } - - impl BraveConfig { - pub fn from_env() -> Result { - let api_key = std::env::var("BRAVE_API_KEY")?; - Ok(Self { - api_key, - base_url: "https://api.search.brave.com/res/v1".to_string(), - }) - } - } - ``` -2. In `src/providers/brave/client.rs`, define: - ```rust - use async_trait::async_trait; - use crate::domain::error::SearchError; - use crate::domain::provider::{ProviderCapabilities, SearchProvider}; - use crate::domain::query::SearchQuery; - use crate::domain::result::SearchResponse; - use crate::domain::types::SearchType; - use crate::transport::http::HttpClient; - use crate::providers::brave::config::BraveConfig; - use crate::providers::brave::dto::*; - use crate::providers::brave::mapper::*; - - pub struct BraveProvider { - client: C, - config: BraveConfig, - } - - impl BraveProvider { - pub fn new(client: C, config: BraveConfig) -> Self { - Self { client, config } - } - } - - #[async_trait] - impl SearchProvider for BraveProvider { - fn id(&self) -> String { - "brave".to_string() - } - - fn capabilities(&self) -> ProviderCapabilities { - ProviderCapabilities { - web: true, - news: true, - images: true, - videos: true, - pagination: false, - safe_search: true, - time_range_filter: true, - } - } - - async fn search(&self, query: &SearchQuery) -> Result { - let endpoint = match query.search_type { - SearchType::Web => "web/search", - SearchType::News => "news/search", - SearchType::Images => "images/search", - SearchType::Videos => "videos/search", - }; - let url = format!("{}/{}", self.config.base_url, endpoint); - - let mut params: Vec<(String, String)> = vec![ - ("q".to_string(), query.text.clone()), - ]; - if let Some(limit) = query.limit { - params.push(("count".to_string(), limit.to_string())); - } - if let Some(offset) = query.offset { - params.push(("offset".to_string(), offset.to_string())); - } - if let Some(ss) = query.safe_search { - let val = match ss { - crate::domain::types::SafeSearch::Off => "off", - crate::domain::types::SafeSearch::Moderate => "moderate", - crate::domain::types::SafeSearch::Strict => "strict", - }; - params.push(("safesearch".to_string(), val.to_string())); - } - if let Some(ref country) = query.country { - params.push(("country".to_string(), country.clone())); - } - if let Some(ref lang) = query.language { - params.push(("search_lang".to_string(), lang.clone())); - } - if let Some(ref tr) = query.time_range { - let val = match tr { - crate::domain::types::TimeRange::Day => "day", - crate::domain::types::TimeRange::Week => "week", - crate::domain::types::TimeRange::Month => "month", - crate::domain::types::TimeRange::Year => "year", - }; - params.push(("freshness".to_string(), val.to_string())); - } - - let headers = vec![ - ("Accept".to_string(), "application/json".to_string()), - ("X-Subscription-Token".to_string(), self.config.api_key.clone()), - ]; - - match query.search_type { - SearchType::Web => { - let dto: BraveWebResponse = self.client.get_json(&url, headers, params).await?; - Ok(map_web_response(dto)) - } - SearchType::News => { - let dto: BraveNewsResponse = self.client.get_json(&url, headers, params).await?; - Ok(map_news_response(dto)) - } - SearchType::Images => { - let dto: BraveImagesResponse = self.client.get_json(&url, headers, params).await?; - Ok(map_images_response(dto)) - } - SearchType::Videos => { - let dto: BraveVideosResponse = self.client.get_json(&url, headers, params).await?; - Ok(map_videos_response(dto)) - } - } - } - } - ``` -3. Add a `#[cfg(test)]` mock `HttpClient` that returns a hardcoded JSON string and assert `BraveProvider::search` produces the expected `SearchResponse`. -4. Run `cargo test --lib`. - ---- diff --git a/.artifacts/plan/2026-04-14_search-cli/tickets/T008.md b/.artifacts/plan/2026-04-14_search-cli/tickets/T008.md deleted file mode 100644 index a795a1f..0000000 --- a/.artifacts/plan/2026-04-14_search-cli/tickets/T008.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -title: "T008: Implement SearchService" -type: plan_ticket -task_id: "T008" -parent_plan: "../PLAN.md" -created_at: "2026-04-14T17:44:55Z" -tags: [ticket, plan] ---- - -# T008: Implement SearchService - -**Summary**: Build the application-layer `SearchService` that accepts a `dyn SearchProvider` and orchestrates searches. - -**Owner**: backend - -**Estimate**: 20m - -**Dependencies**: T004, T007 - -**Target milestone**: M3 - -**Acceptance test**: `cargo check` compiles `SearchService` and a test proves it delegates to a mocked `SearchProvider`. - -**Files/modules touched**: -- `src/app/mod.rs` -- `src/app/search_service.rs` - -**Steps**: -1. Create `src/app/mod.rs` with `pub mod search_service;`. -2. In `src/app/search_service.rs`, define: - ```rust - use crate::domain::error::SearchError; - use crate::domain::provider::SearchProvider; - use crate::domain::query::SearchQuery; - use crate::domain::result::SearchResponse; - - pub struct SearchService { - provider: Box, - } - - impl SearchService { - pub fn new(provider: Box) -> Self { - Self { provider } - } - - pub async fn search(&self, query: SearchQuery) -> Result { - self.provider.search(&query).await - } - } - ``` -3. Add a test that creates a mock `SearchProvider` (implement the trait on a simple struct) and verify `SearchService::search` returns the mock response. -4. Run `cargo test --lib`. - ---- diff --git a/.artifacts/plan/2026-04-14_search-cli/tickets/T009.md b/.artifacts/plan/2026-04-14_search-cli/tickets/T009.md deleted file mode 100644 index d070b42..0000000 --- a/.artifacts/plan/2026-04-14_search-cli/tickets/T009.md +++ /dev/null @@ -1,110 +0,0 @@ ---- -title: "T009: Implement CLI argument parsing" -type: plan_ticket -task_id: "T009" -parent_plan: "../PLAN.md" -created_at: "2026-04-14T17:44:55Z" -tags: [ticket, plan] ---- - -# T009: Implement CLI argument parsing - -**Summary**: Use `clap` to parse user input into a `SearchCommand` struct with query, search type, limit, and output format. - -**Owner**: backend - -**Estimate**: 25m - -**Dependencies**: T001, T002 - -**Target milestone**: M3 - -**Acceptance test**: `cargo run -- --help` displays all options and `cargo run -- "rust"` defaults to web search. - -**Files/modules touched**: -- `src/cli/mod.rs` -- `src/cli/args.rs` - -**Steps**: -1. Create `src/cli/mod.rs` with: - ```rust - pub mod args; - pub mod output; - ``` -2. In `src/cli/args.rs`, define: - ```rust - use clap::{Parser, ValueEnum}; - use crate::domain::types::SearchType; - - #[derive(Debug, Clone, ValueEnum)] - pub enum CliSearchType { - Web, - News, - Images, - Videos, - } - - impl From for SearchType { - fn from(val: CliSearchType) -> Self { - match val { - CliSearchType::Web => SearchType::Web, - CliSearchType::News => SearchType::News, - CliSearchType::Images => SearchType::Images, - CliSearchType::Videos => SearchType::Videos, - } - } - } - - #[derive(Parser, Debug)] - #[command(name = "search-cli")] - #[command(about = "Provider-agnostic search CLI")] - pub struct CliArgs { - #[arg(help = "Search query text")] - pub query: String, - - #[arg(short, long, value_enum, default_value = "web")] - pub search_type: CliSearchType, - - #[arg(short, long)] - pub limit: Option, - - #[arg(long)] - pub offset: Option, - - #[arg(long, value_enum)] - pub safe_search: Option, - - #[arg(long)] - pub country: Option, - - #[arg(long)] - pub language: Option, - } - ``` - Note: `SafeSearch` already derives the necessary traits, but `clap::ValueEnum` requires additional derive. If compilation fails, add a manual mapping instead of using `value_enum` on `SafeSearch`. Create a `CliSafeSearch` enum and map it if needed. -3. Update `src/main.rs` to parse args: - ```rust - use clap::Parser; - use search_cli::cli::args::CliArgs; - - #[tokio::main] - async fn main() { - let _args = CliArgs::parse(); - println!("{:?}", _args); - } - ``` - (Temporarily expose `cli` module from a lib or keep everything in `main.rs` if the project is bin-only.) - - **Important**: Since this is a binary crate, either make `main.rs` contain the modules directly, or create `src/lib.rs`. To keep it simple, declare modules in `main.rs`: - ```rust - mod app; - mod cli; - mod domain; - mod providers; - mod transport; - ``` - Update `main.rs` to include these mod declarations and parse args. -4. Run `cargo run -- --help` and confirm output. -5. Run `cargo run -- "rust"` and confirm it prints the parsed args. - ---- diff --git a/.artifacts/plan/2026-04-14_search-cli/tickets/T010.md b/.artifacts/plan/2026-04-14_search-cli/tickets/T010.md deleted file mode 100644 index c029dd5..0000000 --- a/.artifacts/plan/2026-04-14_search-cli/tickets/T010.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -title: "T010: Implement output rendering" -type: plan_ticket -task_id: "T010" -parent_plan: "../PLAN.md" -created_at: "2026-04-14T17:44:55Z" -tags: [ticket, plan] ---- - -# T010: Implement output rendering - -**Summary**: Create a simple text renderer that prints `SearchResponse` results in a human-readable format. - -**Owner**: backend - -**Estimate**: 20m - -**Dependencies**: T002, T009 - -**Target milestone**: M3 - -**Acceptance test**: A test provides a `SearchResponse` and the renderer returns a string containing all result titles and URLs. - -**Files/modules touched**: -- `src/cli/output.rs` - -**Steps**: -1. In `src/cli/output.rs`, define: - ```rust - use crate::domain::result::{SearchResponse, SearchResult}; - - pub fn render_text(response: &SearchResponse) -> String { - let mut lines = vec![ - format!("Provider: {}", response.provider), - format!("Query: {}", response.query), - format!("Results: {}", response.results.len()), - String::new(), - ]; - for (i, result) in response.results.iter().enumerate() { - match result { - SearchResult::Web(r) => { - lines.push(format!("{}. [{}]", i + 1, r.title)); - lines.push(format!(" URL: {}", r.url)); - if let Some(s) = &r.snippet { - lines.push(format!(" {}", s)); - } - } - SearchResult::News(r) => { - lines.push(format!("{}. [NEWS] {}", i + 1, r.title)); - lines.push(format!(" URL: {}", r.url)); - if let Some(s) = &r.source { - lines.push(format!(" Source: {}", s)); - } - } - SearchResult::Image(r) => { - lines.push(format!("{}. [IMAGE] {}", i + 1, r.title)); - lines.push(format!(" URL: {}", r.url)); - } - SearchResult::Video(r) => { - lines.push(format!("{}. [VIDEO] {}", i + 1, r.title)); - lines.push(format!(" URL: {}", r.url)); - } - } - lines.push(String::new()); - } - lines.join("\n") - } - ``` -2. Add a unit test that constructs a `SearchResponse` with mixed results and asserts the rendered string contains expected substrings. -3. Run `cargo test --lib`. - ---- diff --git a/.artifacts/plan/2026-04-14_search-cli/tickets/T011.md b/.artifacts/plan/2026-04-14_search-cli/tickets/T011.md deleted file mode 100644 index 61bbb4c..0000000 --- a/.artifacts/plan/2026-04-14_search-cli/tickets/T011.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -title: "T011: Wire main.rs and run end-to-end" -type: plan_ticket -task_id: "T011" -parent_plan: "../PLAN.md" -created_at: "2026-04-14T17:44:55Z" -tags: [ticket, plan] ---- - -# T011: Wire main.rs and run end-to-end - -**Summary**: Connect CLI parsing, `SearchService`, `BraveProvider`, and output rendering in `main.rs` so the CLI performs a live search. - -**Owner**: backend - -**Estimate**: 25m - -**Dependencies**: T007, T008, T009, T010 - -**Target milestone**: M4 - -**Acceptance test**: `cargo run -- "rust programming"` successfully queries Brave and prints at least one search result. If API key is missing, it prints a clean error message. - -**Files/modules touched**: -- `src/main.rs` - -**Steps**: -1. In `src/main.rs`, replace contents with: - ```rust - mod app; - mod cli; - mod domain; - mod providers; - mod transport; - - use clap::Parser; - use cli::args::CliArgs; - use cli::output::render_text; - use app::search_service::SearchService; - use domain::query::SearchQuery; - use domain::types::SafeSearch; - use providers::brave::client::BraveProvider; - use providers::brave::config::BraveConfig; - use transport::http::ReqwestHttpClient; - - #[tokio::main] - async fn main() { - dotenvy::dotenv().ok(); - - let args = CliArgs::parse(); - - let config = match BraveConfig::from_env() { - Ok(c) => c, - Err(e) => { - eprintln!("Failed to load Brave config: {}", e); - std::process::exit(1); - } - }; - - let client = ReqwestHttpClient::new(); - let provider = BraveProvider::new(client, config); - let service = SearchService::new(Box::new(provider)); - - let query = SearchQuery { - text: args.query, - search_type: args.search_type.into(), - limit: args.limit, - offset: args.offset, - safe_search: args.safe_search, - country: args.country, - language: args.language, - time_range: None, - }; - - match service.search(query).await { - Ok(response) => { - println!("{}", render_text(&response)); - } - Err(e) => { - eprintln!("Search failed: {}", e); - std::process::exit(1); - } - } - } - ``` - Note: If `SafeSearch` does not implement `clap::ValueEnum`, adjust `CliArgs` to use a local `CliSafeSearch` enum and map it in the `SearchQuery` construction. -2. Ensure `cargo check` passes. -3. Run `cargo run -- "rust programming"` and verify it returns search results. -4. Run `cargo run -- "rust" --search-type news` and verify news results. -5. Run `cargo run -- "cats" --search-type images --limit 3` and verify image results. - ---- diff --git a/.artifacts/plan/2026-04-14_search-cli/tickets/T012.md b/.artifacts/plan/2026-04-14_search-cli/tickets/T012.md deleted file mode 100644 index 6921ebe..0000000 --- a/.artifacts/plan/2026-04-14_search-cli/tickets/T012.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -title: "T012: Add boundary tests and CI-ready verification" -type: plan_ticket -task_id: "T012" -parent_plan: "../PLAN.md" -created_at: "2026-04-14T17:44:55Z" -tags: [ticket, plan] ---- - -# T012: Add boundary tests and CI-ready verification - -**Summary**: Add integration-style tests at the app and provider boundaries, and ensure `cargo test` passes cleanly. - -**Owner**: backend - -**Estimate**: 30m - -**Dependencies**: T011 - -**Target milestone**: M4 - -**Acceptance test**: `cargo test` passes with at least one test for mapper, one for `BraveProvider` with mock HTTP, and one for `SearchService` with mock provider. - -**Files/modules touched**: -- `src/providers/brave/mapper.rs` -- `src/providers/brave/client.rs` -- `src/app/search_service.rs` - -**Steps**: -1. In `src/providers/brave/mapper.rs`, ensure the existing `#[cfg(test)]` module covers all four search types. -2. In `src/providers/brave/client.rs`, ensure the mock HTTP test validates that headers include `X-Subscription-Token` and that query params include `q`, `count`, and `safesearch`. -3. In `src/app/search_service.rs`, ensure the mock provider test validates delegation. -4. Run `cargo test` and fix any failures. -5. Run `cargo clippy` (if available) and fix warnings. - -## Risks & Mitigations - -| Risk | Mitigation | -|------|------------| -| Brave API schema differs from documented DTOs | Start with minimal fields; mapper defaults `Option` values to empty strings rather than failing | -| `clap` `ValueEnum` incompatibility with domain enums | Use separate CLI enums and map them explicitly to domain types | -| Rate limiting during manual testing | Keep query counts low; use `limit` flag; mock HTTP for automated tests | -| Missing env var in CI/test environments | `BraveConfig::from_env()` returns clear error; tests use mock config | - -## Test Strategy - -- **Mapper tests**: Construct minimal DTOs, assert domain output (T006). -- **Provider tests**: Mock `HttpClient` to return JSON, assert `BraveProvider` produces `SearchResponse` (T007). -- **Service tests**: Mock `SearchProvider`, assert `SearchService` delegates correctly (T008). -- **Renderer tests**: Provide `SearchResponse`, assert string output contains expected data (T010). -- **E2E manual**: Run CLI with live Brave API for web, news, images, and video queries (T011). - -## References - -- PRD.md sections 1–10 (domain design, provider trait, Brave mapping, module layout) -- `Cargo.toml` dependency versions aligned with PRD async/HTTP stack -- Brave Search API docs: https://api-dashboard.search.brave.com/documentation - -## Final Gate - -- **Output summary**: plan dir `.artifacts/plan/2026-04-14_search-cli/`, 4 milestones, 12 tickets -- **Next step**: proceed to execute-phase with `.artifacts/plan/2026-04-14_search-cli/PLAN.md` diff --git a/.artifacts/plan/2026-04-15_16-22-21_exa-provider/PLAN.md b/.artifacts/plan/2026-04-15_16-22-21_exa-provider/PLAN.md deleted file mode 100644 index 63029e4..0000000 --- a/.artifacts/plan/2026-04-15_16-22-21_exa-provider/PLAN.md +++ /dev/null @@ -1,311 +0,0 @@ ---- -title: "Exa provider implementation plan" -link: "exa-provider-implementation-plan" -type: implementation_plan -ontological_relations: - - relates_to: [[exa-provider-contract-map-research]] - - relates_to: [[provider-integration-map-research]] -tags: [plan, exa, provider, rust, coding] -uuid: "ac443d51-2b38-49bd-813b-debcb545248f" -created_at: "2026-04-15T21:23:23Z" -parent_research: ".artifacts/research/2026-04-15_21-19-47_exa-provider-contract-map.md" -git_commit_at_plan: "78610af" ---- - -## Goal - -Add Exa as a selectable search provider in the Rust CLI while keeping the current domain and application contracts unchanged. - -**Out of scope**: replacing Brave as the default provider, changing `SearchQuery` or `SearchResponse`, adding Exa-specific deep-search/output features, adding Exa image/video support, packaging or deployment work. - -## Scope & Assumptions - -**IN scope**: -- Extend the shared HTTP transport so providers can issue JSON `POST` requests. -- Add an `src/providers/exa/` adapter with config, DTOs, mapper, and provider client. -- Preserve the existing domain layer and `SearchService` trait boundary. -- Add CLI-level provider selection so Exa is reachable at runtime. -- Add focused regression tests for the new provider and the widened transport trait. - -**OUT of scope**: -- Any edits to `src/domain/` types or provider trait shape. -- Exa support for `SearchType::Images` or `SearchType::Videos`. -- Silent fallback behavior for unsupported Exa query features. -- New output formats or rich structured rendering. -- User-facing docs beyond minimal developer architecture notes. - -**Assumptions**: -- `EXA_API_KEY` will be supplied in the environment the same way `BRAVE_API_KEY` is today. -- Exa integration will use raw `reqwest` through the existing `HttpClient` abstraction rather than adding an Exa SDK dependency. -- Exa `moderation` is a boolean, so both `SafeSearch::Moderate` and `SafeSearch::Strict` will map to `true`. -- Exa does not expose request fields matching the current `offset` and `language` domain inputs in the referenced docs, so the adapter will reject those when `--provider exa` is selected instead of ignoring them. - -## Deliverables - -- `src/transport/http.rs` updated with shared JSON `POST` support. -- `src/providers/{mod.rs,exa/{mod.rs,config.rs,dto.rs,mapper.rs,client.rs}}` -- `src/cli/args.rs` updated with provider selection. -- `src/main.rs` updated to instantiate either Brave or Exa. -- `Cargo.toml` updated only if an ISO-8601 time helper crate is needed for Exa time-range translation. -- Targeted unit tests in the touched provider and transport modules. -- `docs/architecture.md` refreshed where the runtime provider-selection story changes. - -## Readiness - -- Repo is already bootstrapped and compiles before this work begins. -- Research artifacts exist at: - - `.artifacts/research/2026-04-15_21-10-50_provider-integration-map.md` - - `.artifacts/research/2026-04-15_21-19-47_exa-provider-contract-map.md` -- Current git plan baseline: - - commit: `78610af` - - working tree includes local doc/artifact changes (`AGENTS.md`, removed `PRD.md`, new `.artifacts/research/`) -- Execution requires environment access to both `BRAVE_API_KEY` and `EXA_API_KEY` for live manual smoke checks after implementation. - -## Milestones - -- **M1**: Shared transport and Exa scaffolding compile. -- **M2**: Exa request/response translation works for web and news searches. -- **M3**: CLI and binary composition can select Brave or Exa without touching domain/app layers. -- **M4**: Focused regression tests and architecture notes match the new provider surface. - -## Ticket Index - - - -| Task | Title | Ticket | -|---|---|---| -| T001 | Extend shared HTTP transport for JSON POST requests | [tickets/T001.md](tickets/T001.md) | -| T002 | Add Exa provider scaffolding, config, and serde DTOs | [tickets/T002.md](tickets/T002.md) | -| T003 | Map Exa responses into the existing domain result model | [tickets/T003.md](tickets/T003.md) | -| T004 | Implement the Exa provider client and query translation | [tickets/T004.md](tickets/T004.md) | -| T005 | Wire provider selection into the CLI and binary composition | [tickets/T005.md](tickets/T005.md) | -| T006 | Refresh regression coverage and architecture notes for multi-provider runtime | [tickets/T006.md](tickets/T006.md) | - - - -## Work Breakdown (Tasks) - -### T001: Extend shared HTTP transport for JSON POST requests - -**Summary**: Add JSON `POST` support to the existing `HttpClient` trait and reqwest adapter so provider implementations can call Exa without bypassing the transport layer. - -**Owner**: backend - -**Estimate**: 1h - -**Dependencies**: - -**Target milestone**: M1 - -**Acceptance test**: `cargo test transport::http::tests::test_post_json_decodes_success_response` - -**Files/modules touched**: -- `src/transport/http.rs` - -**Steps**: -1. Add a new async trait method to `HttpClient`, alongside `get_json`, for posting a JSON body and decoding the JSON response. -2. Keep the trait generic over the response type and request body type so provider adapters can reuse it without casting through `serde_json::Value`. -3. Refactor `ReqwestHttpClient` so GET and POST share a single private response-status handling path for auth, rate limit, non-2xx provider errors, and JSON decode errors. -4. Implement the new `post_json` method with reqwest `.post(url).json(&body)`, preserving caller-supplied headers. -5. Add a focused test in `src/transport/http.rs` that spins up a local `tokio::net::TcpListener`, returns a fixed JSON body, and verifies the new POST method decodes it successfully. - -### T002: Add Exa provider scaffolding, config, and serde DTOs - -**Summary**: Create the Exa provider module layout and define the request/response structs needed for the subset of the Exa API this CLI will consume. - -**Owner**: backend - -**Estimate**: 1.5h - -**Dependencies**: T001 - -**Target milestone**: M1 - -**Acceptance test**: `cargo test providers::exa::dto::tests::test_exa_search_response_deserializes_minimal_payload` - -**Files/modules touched**: -- `src/providers/mod.rs` -- `src/providers/exa/mod.rs` -- `src/providers/exa/config.rs` -- `src/providers/exa/dto.rs` - -**Steps**: -1. Export `pub mod exa;` from `src/providers/mod.rs` and create `src/providers/exa/mod.rs` with `pub mod client;`, `pub mod config;`, `pub mod dto;`, and `pub mod mapper;`. -2. Add `ExaConfig` in `src/providers/exa/config.rs` with `api_key` and `base_url`, and implement `from_env()` to read `EXA_API_KEY` and default `base_url` to `https://api.exa.ai`. -3. In `src/providers/exa/dto.rs`, define the request structs needed by the adapter using `#[serde(rename_all = "camelCase")]`: - - `ExaSearchRequest` - - `ExaContentsRequest` -4. In the same file, define only the response structs/fields the adapter will map: - - `ExaSearchResponse` - - `ExaResult` - - any small nested types required for fields consumed by the mapper -5. Keep unknown Exa fields ignored by omission rather than attempting to model the full API surface. -6. Add a DTO unit test that deserializes a minimal Exa search payload containing one result and asserts `request_id`, `search_type`, `published_date`, and `summary` are populated correctly. - -### T003: Map Exa responses into the existing domain result model - -**Summary**: Implement mapper functions that convert Exa search results into the current `SearchResponse` and `SearchResult` variants without changing shared domain types. - -**Owner**: backend - -**Estimate**: 1h - -**Dependencies**: T002 - -**Target milestone**: M2 - -**Acceptance test**: `cargo test providers::exa::mapper::tests::test_map_news_response_prefers_summary_and_preserves_author` - -**Files/modules touched**: -- `src/providers/exa/mapper.rs` - -**Steps**: -1. Add `map_web_response` and `map_news_response` in `src/providers/exa/mapper.rs`. -2. Set `SearchResponse.provider` to `"exa"` and `SearchResponse.query` from the original query string passed into the mapper, because the referenced Exa response contract does not expose the original query text. -3. For web results, map Exa `title`, `url`, and `summary`/`text` into `WebResult`, preferring `summary` and falling back to `text`. -4. For news results, map Exa `title`, `url`, `summary`/`text`, `author`, and `publishedDate` into `NewsResult`, using `author` as `source`. -5. Set `total_estimated` and `next_page` to `None`, matching the current normalized model when the provider does not expose those values in the referenced contract. -6. Add a mapper test that constructs a minimal Exa news DTO and proves the mapper uses `summary` before `text` and carries `author` into `source`. - -### T004: Implement the Exa provider client and query translation - -**Summary**: Add `ExaProvider` that translates the existing `SearchQuery` into Exa request bodies, advertises accurate capabilities, and fails explicitly on unsupported inputs. - -**Owner**: backend - -**Estimate**: 2h - -**Dependencies**: T001, T002, T003 - -**Target milestone**: M2 - -**Acceptance test**: `cargo test providers::exa::client::tests::test_exa_provider_news_search_posts_expected_payload` - -**Files/modules touched**: -- `Cargo.toml` -- `src/providers/exa/client.rs` - -**Steps**: -1. Add a narrow time-handling dependency in `Cargo.toml` only if needed to produce ISO-8601 UTC timestamps for Exa `startPublishedDate` and `endPublishedDate`. -2. Implement `ExaProvider` with `new(client, config)` and a `SearchProvider` implementation whose `id()` returns `"exa"`. -3. Set Exa capabilities to: - - `web = true` - - `news = true` - - `images = false` - - `videos = false` - - `pagination = false` - - `safe_search = true` - - `time_range_filter = true` -4. Translate the shared `SearchQuery` into Exa request fields as follows: - - `text` -> `query` - - `limit` -> `numResults` - - `search_type = Web` -> omit `category` - - `search_type = News` -> `category = "news"` - - `safe_search = Off` -> `moderation = false` - - `safe_search = Moderate | Strict` -> `moderation = true` - - `country` -> `userLocation` - - `time_range` -> `startPublishedDate` and `endPublishedDate` in ISO-8601 UTC - - request `contents.text = true` - - set request `type = "auto"` -5. Reject unsupported Exa query inputs with `SearchError::InvalidQuery` rather than silently ignoring them: - - `SearchType::Images` - - `SearchType::Videos` - - any non-`None` `offset` - - any non-`None` `language` -6. Send `POST {base_url}/search` with the `x-api-key` header through the new transport method and dispatch the decoded response through the Exa mapper selected by the original `SearchType`. -7. Add a mocked-provider test that asserts the request body contains `category: "news"`, `numResults`, `moderation`, and published-date window fields, then verifies the mapped response comes back with provider `"exa"`. - -### T005: Wire provider selection into the CLI and binary composition - -**Summary**: Make the provider selectable at runtime while preserving Brave as the default and leaving the domain/application layers untouched. - -**Owner**: backend - -**Estimate**: 1.5h - -**Dependencies**: T004 - -**Target milestone**: M3 - -**Acceptance test**: `cargo test cli::args::tests::test_cli_provider_parses_exa_and_defaults_to_brave` - -**Files/modules touched**: -- `src/cli/args.rs` -- `src/main.rs` - -**Steps**: -1. Add a `CliProvider` `ValueEnum` to `src/cli/args.rs` with `Brave` and `Exa`. -2. Add a `--provider` flag (short `-p`) to `CliArgs` with default value `brave`. -3. Keep the existing `SearchQuery` construction unchanged so the provider adapter remains responsible for validating unsupported inputs. -4. Update `src/main.rs` to import `ExaProvider` and `ExaConfig`, then match on `args.provider` to build the concrete provider instance: - - Brave branch loads `BraveConfig` and constructs `BraveProvider` - - Exa branch loads `ExaConfig` and constructs `ExaProvider` -5. Keep Brave as the default branch so existing invocations still behave the same when `--provider` is omitted. -6. Add a CLI parsing unit test proving `--provider exa` parses and that the default remains `Brave`. - -### T006: Refresh regression coverage and architecture notes for multi-provider runtime - -**Summary**: Update affected tests and architecture notes so the widened transport trait and multi-provider binary wiring are documented and enforced by focused regressions. - -**Owner**: backend - -**Estimate**: 1h - -**Dependencies**: T001, T004, T005 - -**Target milestone**: M4 - -**Acceptance test**: `cargo test providers::exa::client::tests::test_exa_provider_rejects_unsupported_query_fields` - -**Files/modules touched**: -- `src/providers/brave/client.rs` -- `src/providers/exa/client.rs` -- `docs/architecture.md` - -**Steps**: -1. Update the existing Brave provider test mock in `src/providers/brave/client.rs` so it implements the expanded `HttpClient` trait without changing Brave behavior. -2. Add a focused Exa provider regression test that proves unsupported query fields (`Images`, `Videos`, `offset`, or `language`) return `SearchError::InvalidQuery`. -3. Refresh `docs/architecture.md` so the provider-addition section distinguishes: - - unchanged domain/app boundaries - - provider-specific transport needs - - current binary wiring via CLI provider selection -4. Leave `tests/architecture_test.rs` unchanged unless the implementation introduces a real boundary violation; the current boundary rules should still hold. - -## Risks & Mitigations - -- Exa request contract drift: mitigate by implementing only the fields cited in the current Exa docs and keeping DTOs narrow. -- Safe-search fidelity loss: Exa exposes boolean moderation only, so `Moderate` and `Strict` collapse to the same provider value; document this in code comments near the mapping. -- Unsupported query-field confusion: reject unsupported Exa inputs explicitly instead of silently dropping them. -- Time-range serialization complexity: contain any new date/time dependency to the Exa client request translation path. -- Architecture doc drift: refresh the provider-selection section in `docs/architecture.md` as part of the same change set. - -## Test Strategy - -- Add one focused test per task only, centered on the newly introduced behavior. -- Prefer unit tests with mock transport over live API calls. -- Keep existing Brave tests compiling after the `HttpClient` trait expands. -- Defer live `cargo run -- --provider exa ...` smoke checks to execution/verification, not plan work. - -## References - -- `.artifacts/research/2026-04-15_21-19-47_exa-provider-contract-map.md` -- `.artifacts/research/2026-04-15_21-10-50_provider-integration-map.md` -- `src/domain/provider.rs:8` -- `src/domain/provider.rs:19` -- `src/transport/http.rs:5` -- `src/transport/http.rs:31` -- `src/providers/brave/client.rs:24` -- `src/providers/brave/client.rs:41` -- `src/main.rs:12` -- `src/main.rs:42` -- `src/main.rs:51` -- `src/cli/args.rs:36` -- `docs/architecture.md:151` -- `https://exa.ai/docs/reference/search` -- `https://exa.ai/docs/reference/search-api-guide` -- `https://exa.ai/docs/reference/search-api-guide-for-coding-agents` - -## Final Gate - -- **Output summary**: plan dir path `.artifacts/plan/2026-04-15_16-22-21_exa-provider/`, milestone count `4`, ticket count `6` -- **Next step**: review this plan, then proceed to `grill-me` or `execute-phase` using `.artifacts/plan/2026-04-15_16-22-21_exa-provider/PLAN.md` diff --git a/.artifacts/plan/2026-04-15_16-22-21_exa-provider/tickets/INDEX.md b/.artifacts/plan/2026-04-15_16-22-21_exa-provider/tickets/INDEX.md deleted file mode 100644 index c868e8f..0000000 --- a/.artifacts/plan/2026-04-15_16-22-21_exa-provider/tickets/INDEX.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: "Ticket Index" -type: ticket_index -parent_plan: "../PLAN.md" -created_at: "2026-04-15T21:25:57Z" -tags: [ticket, plan] ---- - -# Ticket Index - -| Task | Title | Ticket | -|---|---|---| -| T001 | Extend shared HTTP transport for JSON POST requests | [T001](./T001.md) | -| T002 | Add Exa provider scaffolding, config, and serde DTOs | [T002](./T002.md) | -| T003 | Map Exa responses into the existing domain result model | [T003](./T003.md) | -| T004 | Implement the Exa provider client and query translation | [T004](./T004.md) | -| T005 | Wire provider selection into the CLI and binary composition | [T005](./T005.md) | -| T006 | Refresh regression coverage and architecture notes for multi-provider runtime | [T006](./T006.md) | diff --git a/.artifacts/plan/2026-04-15_16-22-21_exa-provider/tickets/T001.md b/.artifacts/plan/2026-04-15_16-22-21_exa-provider/tickets/T001.md deleted file mode 100644 index abacc49..0000000 --- a/.artifacts/plan/2026-04-15_16-22-21_exa-provider/tickets/T001.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -title: "T001: Extend shared HTTP transport for JSON POST requests" -type: plan_ticket -task_id: "T001" -parent_plan: "../PLAN.md" -created_at: "2026-04-15T21:25:57Z" -tags: [ticket, plan] ---- - -# T001: Extend shared HTTP transport for JSON POST requests - -**Summary**: Add JSON `POST` support to the existing `HttpClient` trait and reqwest adapter so provider implementations can call Exa without bypassing the transport layer. - -**Owner**: backend - -**Estimate**: 1h - -**Dependencies**: - -**Target milestone**: M1 - -**Acceptance test**: `cargo test transport::http::tests::test_post_json_decodes_success_response` - -**Files/modules touched**: -- `src/transport/http.rs` - -**Steps**: -1. Add a new async trait method to `HttpClient`, alongside `get_json`, for posting a JSON body and decoding the JSON response. -2. Keep the trait generic over the response type and request body type so provider adapters can reuse it without casting through `serde_json::Value`. -3. Refactor `ReqwestHttpClient` so GET and POST share a single private response-status handling path for auth, rate limit, non-2xx provider errors, and JSON decode errors. -4. Implement the new `post_json` method with reqwest `.post(url).json(&body)`, preserving caller-supplied headers. -5. Add a focused test in `src/transport/http.rs` that spins up a local `tokio::net::TcpListener`, returns a fixed JSON body, and verifies the new POST method decodes it successfully. diff --git a/.artifacts/plan/2026-04-15_16-22-21_exa-provider/tickets/T002.md b/.artifacts/plan/2026-04-15_16-22-21_exa-provider/tickets/T002.md deleted file mode 100644 index 4c9dac7..0000000 --- a/.artifacts/plan/2026-04-15_16-22-21_exa-provider/tickets/T002.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -title: "T002: Add Exa provider scaffolding, config, and serde DTOs" -type: plan_ticket -task_id: "T002" -parent_plan: "../PLAN.md" -created_at: "2026-04-15T21:25:57Z" -tags: [ticket, plan] ---- - -# T002: Add Exa provider scaffolding, config, and serde DTOs - -**Summary**: Create the Exa provider module layout and define the request/response structs needed for the subset of the Exa API this CLI will consume. - -**Owner**: backend - -**Estimate**: 1.5h - -**Dependencies**: T001 - -**Target milestone**: M1 - -**Acceptance test**: `cargo test providers::exa::dto::tests::test_exa_search_response_deserializes_minimal_payload` - -**Files/modules touched**: -- `src/providers/mod.rs` -- `src/providers/exa/mod.rs` -- `src/providers/exa/config.rs` -- `src/providers/exa/dto.rs` - -**Steps**: -1. Export `pub mod exa;` from `src/providers/mod.rs` and create `src/providers/exa/mod.rs` with `pub mod client;`, `pub mod config;`, `pub mod dto;`, and `pub mod mapper;`. -2. Add `ExaConfig` in `src/providers/exa/config.rs` with `api_key` and `base_url`, and implement `from_env()` to read `EXA_API_KEY` and default `base_url` to `https://api.exa.ai`. -3. In `src/providers/exa/dto.rs`, define the request structs needed by the adapter using `#[serde(rename_all = "camelCase")]`: - - `ExaSearchRequest` - - `ExaContentsRequest` -4. In the same file, define only the response structs/fields the adapter will map: - - `ExaSearchResponse` - - `ExaResult` - - any small nested types required for fields consumed by the mapper -5. Keep unknown Exa fields ignored by omission rather than attempting to model the full API surface. -6. Add a DTO unit test that deserializes a minimal Exa search payload containing one result and asserts `request_id`, `search_type`, `published_date`, and `summary` are populated correctly. diff --git a/.artifacts/plan/2026-04-15_16-22-21_exa-provider/tickets/T003.md b/.artifacts/plan/2026-04-15_16-22-21_exa-provider/tickets/T003.md deleted file mode 100644 index 7c8cf17..0000000 --- a/.artifacts/plan/2026-04-15_16-22-21_exa-provider/tickets/T003.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -title: "T003: Map Exa responses into the existing domain result model" -type: plan_ticket -task_id: "T003" -parent_plan: "../PLAN.md" -created_at: "2026-04-15T21:25:57Z" -tags: [ticket, plan] ---- - -# T003: Map Exa responses into the existing domain result model - -**Summary**: Implement mapper functions that convert Exa search results into the current `SearchResponse` and `SearchResult` variants without changing shared domain types. - -**Owner**: backend - -**Estimate**: 1h - -**Dependencies**: T002 - -**Target milestone**: M2 - -**Acceptance test**: `cargo test providers::exa::mapper::tests::test_map_news_response_prefers_summary_and_preserves_author` - -**Files/modules touched**: -- `src/providers/exa/mapper.rs` - -**Steps**: -1. Add `map_web_response` and `map_news_response` in `src/providers/exa/mapper.rs`. -2. Set `SearchResponse.provider` to `"exa"` and `SearchResponse.query` from the original query string passed into the mapper, because the referenced Exa response contract does not expose the original query text. -3. For web results, map Exa `title`, `url`, and `summary`/`text` into `WebResult`, preferring `summary` and falling back to `text`. -4. For news results, map Exa `title`, `url`, `summary`/`text`, `author`, and `publishedDate` into `NewsResult`, using `author` as `source`. -5. Set `total_estimated` and `next_page` to `None`, matching the current normalized model when the provider does not expose those values in the referenced contract. -6. Add a mapper test that constructs a minimal Exa news DTO and proves the mapper uses `summary` before `text` and carries `author` into `source`. diff --git a/.artifacts/plan/2026-04-15_16-22-21_exa-provider/tickets/T004.md b/.artifacts/plan/2026-04-15_16-22-21_exa-provider/tickets/T004.md deleted file mode 100644 index acece7b..0000000 --- a/.artifacts/plan/2026-04-15_16-22-21_exa-provider/tickets/T004.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -title: "T004: Implement the Exa provider client and query translation" -type: plan_ticket -task_id: "T004" -parent_plan: "../PLAN.md" -created_at: "2026-04-15T21:25:57Z" -tags: [ticket, plan] ---- - -# T004: Implement the Exa provider client and query translation - -**Summary**: Add `ExaProvider` that translates the existing `SearchQuery` into Exa request bodies, advertises accurate capabilities, and fails explicitly on unsupported inputs. - -**Owner**: backend - -**Estimate**: 2h - -**Dependencies**: T001, T002, T003 - -**Target milestone**: M2 - -**Acceptance test**: `cargo test providers::exa::client::tests::test_exa_provider_news_search_posts_expected_payload` - -**Files/modules touched**: -- `Cargo.toml` -- `src/providers/exa/client.rs` - -**Steps**: -1. Add a narrow time-handling dependency in `Cargo.toml` only if needed to produce ISO-8601 UTC timestamps for Exa `startPublishedDate` and `endPublishedDate`. -2. Implement `ExaProvider` with `new(client, config)` and a `SearchProvider` implementation whose `id()` returns `"exa"`. -3. Set Exa capabilities to: - - `web = true` - - `news = true` - - `images = false` - - `videos = false` - - `pagination = false` - - `safe_search = true` - - `time_range_filter = true` -4. Translate the shared `SearchQuery` into Exa request fields as follows: - - `text` -> `query` - - `limit` -> `numResults` - - `search_type = Web` -> omit `category` - - `search_type = News` -> `category = "news"` - - `safe_search = Off` -> `moderation = false` - - `safe_search = Moderate | Strict` -> `moderation = true` - - `country` -> `userLocation` - - `time_range` -> `startPublishedDate` and `endPublishedDate` in ISO-8601 UTC - - request `contents.text = true` - - set request `type = "auto"` -5. Reject unsupported Exa query inputs with `SearchError::InvalidQuery` rather than silently ignoring them: - - `SearchType::Images` - - `SearchType::Videos` - - any non-`None` `offset` - - any non-`None` `language` -6. Send `POST {base_url}/search` with the `x-api-key` header through the new transport method and dispatch the decoded response through the Exa mapper selected by the original `SearchType`. -7. Add a mocked-provider test that asserts the request body contains `category: "news"`, `numResults`, `moderation`, and published-date window fields, then verifies the mapped response comes back with provider `"exa"`. diff --git a/.artifacts/plan/2026-04-15_16-22-21_exa-provider/tickets/T005.md b/.artifacts/plan/2026-04-15_16-22-21_exa-provider/tickets/T005.md deleted file mode 100644 index 0430b57..0000000 --- a/.artifacts/plan/2026-04-15_16-22-21_exa-provider/tickets/T005.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -title: "T005: Wire provider selection into the CLI and binary composition" -type: plan_ticket -task_id: "T005" -parent_plan: "../PLAN.md" -created_at: "2026-04-15T21:25:57Z" -tags: [ticket, plan] ---- - -# T005: Wire provider selection into the CLI and binary composition - -**Summary**: Make the provider selectable at runtime while preserving Brave as the default and leaving the domain/application layers untouched. - -**Owner**: backend - -**Estimate**: 1.5h - -**Dependencies**: T004 - -**Target milestone**: M3 - -**Acceptance test**: `cargo test cli::args::tests::test_cli_provider_parses_exa_and_defaults_to_brave` - -**Files/modules touched**: -- `src/cli/args.rs` -- `src/main.rs` - -**Steps**: -1. Add a `CliProvider` `ValueEnum` to `src/cli/args.rs` with `Brave` and `Exa`. -2. Add a `--provider` flag (short `-p`) to `CliArgs` with default value `brave`. -3. Keep the existing `SearchQuery` construction unchanged so the provider adapter remains responsible for validating unsupported inputs. -4. Update `src/main.rs` to import `ExaProvider` and `ExaConfig`, then match on `args.provider` to build the concrete provider instance: - - Brave branch loads `BraveConfig` and constructs `BraveProvider` - - Exa branch loads `ExaConfig` and constructs `ExaProvider` -5. Keep Brave as the default branch so existing invocations still behave the same when `--provider` is omitted. -6. Add a CLI parsing unit test proving `--provider exa` parses and that the default remains `Brave`. diff --git a/.artifacts/plan/2026-04-15_16-22-21_exa-provider/tickets/T006.md b/.artifacts/plan/2026-04-15_16-22-21_exa-provider/tickets/T006.md deleted file mode 100644 index c90b9c6..0000000 --- a/.artifacts/plan/2026-04-15_16-22-21_exa-provider/tickets/T006.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -title: "T006: Refresh regression coverage and architecture notes for multi-provider runtime" -type: plan_ticket -task_id: "T006" -parent_plan: "../PLAN.md" -created_at: "2026-04-15T21:25:57Z" -tags: [ticket, plan] ---- - -# T006: Refresh regression coverage and architecture notes for multi-provider runtime - -**Summary**: Update affected tests and architecture notes so the widened transport trait and multi-provider binary wiring are documented and enforced by focused regressions. - -**Owner**: backend - -**Estimate**: 1h - -**Dependencies**: T001, T004, T005 - -**Target milestone**: M4 - -**Acceptance test**: `cargo test providers::exa::client::tests::test_exa_provider_rejects_unsupported_query_fields` - -**Files/modules touched**: -- `src/providers/brave/client.rs` -- `src/providers/exa/client.rs` -- `docs/architecture.md` - -**Steps**: -1. Update the existing Brave provider test mock in `src/providers/brave/client.rs` so it implements the expanded `HttpClient` trait without changing Brave behavior. -2. Add a focused Exa provider regression test that proves unsupported query fields (`Images`, `Videos`, `offset`, or `language`) return `SearchError::InvalidQuery`. -3. Refresh `docs/architecture.md` so the provider-addition section distinguishes: - - unchanged domain/app boundaries - - provider-specific transport needs - - current binary wiring via CLI provider selection -4. Leave `tests/architecture_test.rs` unchanged unless the implementation introduces a real boundary violation; the current boundary rules should still hold. - -## Risks & Mitigations - -- Exa request contract drift: mitigate by implementing only the fields cited in the current Exa docs and keeping DTOs narrow. -- Safe-search fidelity loss: Exa exposes boolean moderation only, so `Moderate` and `Strict` collapse to the same provider value; document this in code comments near the mapping. -- Unsupported query-field confusion: reject unsupported Exa inputs explicitly instead of silently dropping them. -- Time-range serialization complexity: contain any new date/time dependency to the Exa client request translation path. -- Architecture doc drift: refresh the provider-selection section in `docs/architecture.md` as part of the same change set. - -## Test Strategy - -- Add one focused test per task only, centered on the newly introduced behavior. -- Prefer unit tests with mock transport over live API calls. -- Keep existing Brave tests compiling after the `HttpClient` trait expands. -- Defer live `cargo run -- --provider exa ...` smoke checks to execution/verification, not plan work. - -## References - -- `.artifacts/research/2026-04-15_21-19-47_exa-provider-contract-map.md` -- `.artifacts/research/2026-04-15_21-10-50_provider-integration-map.md` -- `src/domain/provider.rs:8` -- `src/domain/provider.rs:19` -- `src/transport/http.rs:5` -- `src/transport/http.rs:31` -- `src/providers/brave/client.rs:24` -- `src/providers/brave/client.rs:41` -- `src/main.rs:12` -- `src/main.rs:42` -- `src/main.rs:51` -- `src/cli/args.rs:36` -- `docs/architecture.md:151` -- `https://exa.ai/docs/reference/search` -- `https://exa.ai/docs/reference/search-api-guide` -- `https://exa.ai/docs/reference/search-api-guide-for-coding-agents` - -## Final Gate - -- **Output summary**: plan dir path `.artifacts/plan/2026-04-15_16-22-21_exa-provider/`, milestone count `4`, ticket count `6` -- **Next step**: review this plan, then proceed to `grill-me` or `execute-phase` using `.artifacts/plan/2026-04-15_16-22-21_exa-provider/PLAN.md` diff --git a/.artifacts/plan/2026-04-25_13-50-32_provider-registry/PLAN.md b/.artifacts/plan/2026-04-25_13-50-32_provider-registry/PLAN.md deleted file mode 100644 index 5de46ab..0000000 --- a/.artifacts/plan/2026-04-25_13-50-32_provider-registry/PLAN.md +++ /dev/null @@ -1,336 +0,0 @@ ---- -title: "Provider registry implementation plan" -link: "provider-registry-plan" -type: implementation_plan -ontological_relations: - - relates_to: [[search-service-flow-visual]] - - relates_to: [[HARNESS]] -tags: [plan, provider-registry, rust, search-cli, coding] -uuid: "D81E9746-372F-41BA-9CAB-16A8CD55467D" -created_at: "2026-04-25T13:50:32Z" -parent_research: ".artifacts/interface-designs/search-service-flow-visual.html" -git_commit_at_plan: "39c796e" ---- - -## Goal - -Implement a compile-time built-in provider registry/factory that replaces the inline provider construction `match` in `src/main.rs`, scales cleanly as more providers are added, and only registers providers whose required env config is present. - -Out of scope: runtime plugin providers, external provider discovery, config files, redesigning the transport trait, changing provider request/mapper behavior, and adding new search providers. - -## Scope & Assumptions - -IN scope: -- Add a composition/bootstrap module that owns provider registration and `SearchService` construction. -- Add a provider-neutral `ProviderId` separate from `cli::args::CliProvider`. -- Add a registry that maps configured built-in providers to provider builder closures. -- Make production registry creation include Brave only when `BraveConfig::from_env()` succeeds and Exa only when `ExaConfig::from_env()` succeeds. -- Return a clear construction error when a selected provider is not registered because it is not configured. -- Keep `main` responsible for CLI parsing, query construction, output rendering, and process exit. -- Add focused tests for registry availability and `main` wiring boundaries where practical. - -OUT of scope: -- `Box` or transport object erasure. Current `HttpClient` has generic methods and is not object-safe. -- Shared erased config bags such as `ProviderConfig { api_key, base_url, extra }`. -- Lazy provider setup that defers missing-key failures until `search`. -- Runtime provider plugins or dynamic loading. -- Reworking `SearchProvider`, `SearchService`, or provider DTO mapping. - -Assumptions: -- `BraveConfig` and `ExaConfig` remain typed provider-owned config structs. -- `BraveConfig::from_env()` requires `BRAVE_API_KEY`; `ExaConfig::from_env()` requires `EXA_API_KEY`. -- `dotenvy::dotenv().ok()` remains in `main` before production registry creation. -- The architecture boundary tests may be extended, but existing `domain`, `transport`, `providers`, and `app` restrictions must continue to pass. -- The current untracked `.artifacts/interface-designs/` files are planning artifacts and should not affect source implementation. - -## Deliverables - -- `src/bootstrap/mod.rs` -- `src/bootstrap/provider_registry.rs` -- `src/main.rs` updates to call the registry/factory instead of inline provider construction -- Optional focused unit tests inside `src/bootstrap/provider_registry.rs` -- `tests/architecture_test.rs` updates if needed to document the new composition-layer boundary - -## Readiness - -Preconditions: -- Current source tree includes `src/providers/brave/*`, `src/providers/exa/*`, `src/app/search_service.rs`, `src/domain/provider.rs`, and `src/transport/http.rs`. -- The working tree may contain untracked planning artifacts under `.artifacts/interface-designs/`; execution should leave them intact. -- `just check` remains the canonical final verification command. - -What must exist before starting: -- Rust toolchain and dependencies already present. -- No source files need to be generated outside the paths listed in this plan. - -## Milestones - -- M1: Bootstrap registry API and error contract -- M2: Production provider registration from typed env config -- M3: Main wiring migration and architecture guardrails -- M4: Focused tests and final check path - -## Ticket Index - - - -| Task | Title | Ticket | -|---|---|---| -| T001 | Add bootstrap module skeleton and ProviderId | [tickets/T001.md](tickets/T001.md) | -| T002 | Define registry and construction error contract | [tickets/T002.md](tickets/T002.md) | -| T003 | Add production registry that only includes configured providers | [tickets/T003.md](tickets/T003.md) | -| T004 | Convert CliProvider to ProviderId at the binary edge | [tickets/T004.md](tickets/T004.md) | -| T005 | Replace inline provider construction in main | [tickets/T005.md](tickets/T005.md) | -| T006 | Extend architecture guardrails for the composition layer | [tickets/T006.md](tickets/T006.md) | -| T007 | Add focused registry tests | [tickets/T007.md](tickets/T007.md) | -| T008 | Run final local gate and update docs only if source references require it | [tickets/T008.md](tickets/T008.md) | - - - -## Work Breakdown (Tasks) - -### T001: Add bootstrap module skeleton and ProviderId - -**Summary**: Create the composition module and provider-neutral selection type without wiring any providers yet. - -**Owner**: backend - -**Estimate**: 45m - -**Dependencies**: - -**Target milestone**: M1 - -**Acceptance test**: `cargo check` passes with `mod bootstrap;` declared and no provider behavior changed. - -**Files/modules touched**: -- `src/main.rs` -- `src/bootstrap/mod.rs` -- `src/bootstrap/provider_registry.rs` - -**Steps**: -1. Add `mod bootstrap;` near the other module declarations in `src/main.rs`. -2. Create `src/bootstrap/mod.rs` with `pub mod provider_registry;`. -3. Create `src/bootstrap/provider_registry.rs`. -4. Define `#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ProviderId { Brave, Exa }`. -5. Implement `Display` for `ProviderId` with lowercase labels `brave` and `exa`. -6. Do not import `crate::cli` in `src/bootstrap/provider_registry.rs`. - -### T002: Define registry and construction error contract - -**Summary**: Add the provider registry type, provider builder alias, and construction errors used by production service creation. - -**Owner**: backend - -**Estimate**: 1h - -**Dependencies**: T001 - -**Target milestone**: M1 - -**Acceptance test**: Unit test constructs an empty registry and receives `ProviderUnavailable` when building `ProviderId::Brave`. - -**Files/modules touched**: -- `src/bootstrap/provider_registry.rs` - -**Steps**: -1. Import `std::collections::HashMap`, `crate::app::search_service::SearchService`, and `crate::domain::provider::SearchProvider`. -2. Define `pub type ProviderBuilder = Box Box + Send + Sync>;`. -3. Define `pub struct ProviderRegistry { builders: HashMap }`. -4. Add `pub fn empty() -> Self`. -5. Add `pub fn register(&mut self, id: ProviderId, builder: ProviderBuilder)`. -6. Add `pub fn available_providers(&self) -> Vec` that returns stable sorted order `[Brave, Exa]` when present. -7. Define `#[derive(Debug, thiserror::Error)] pub enum BuildSearchServiceError` with `ProviderUnavailable { provider: ProviderId, available: Vec }`. -8. Implement `pub fn build(&self, provider: ProviderId) -> Result` that creates `SearchService::new(builder())` when registered. -9. Add a unit test for empty registry unavailable behavior. - -### T003: Add production registry that only includes configured providers - -**Summary**: Implement env-backed production registration where missing provider keys mean the provider is omitted from the registry. - -**Owner**: backend - -**Estimate**: 1h - -**Dependencies**: T002 - -**Target milestone**: M2 - -**Acceptance test**: Unit test sets only `BRAVE_API_KEY` and confirms `production_from_env()` lists Brave but not Exa. - -**Files/modules touched**: -- `src/bootstrap/provider_registry.rs` - -**Steps**: -1. Import `BraveProvider`, `BraveConfig`, `ExaProvider`, `ExaConfig`, and `ReqwestHttpClient`. -2. Add `pub fn production_from_env() -> Self`. -3. In `production_from_env()`, call `BraveConfig::from_env()`. If it returns `Ok(config)`, register `ProviderId::Brave` with a closure that clones `config` and returns `Box::new(BraveProvider::new(ReqwestHttpClient::new(), config.clone()))`. -4. In `production_from_env()`, call `ExaConfig::from_env()`. If it returns `Ok(config)`, register `ProviderId::Exa` with a closure that clones `config` and returns `Box::new(ExaProvider::new(ReqwestHttpClient::new(), config.clone()))`. -5. If a config load returns `Err(std::env::VarError::NotPresent)`, do not register that provider. -6. If a config load returns `Err(std::env::VarError::NotUnicode(_))`, do not register that provider for now; surface the same `ProviderUnavailable` if selected. -7. Add tests that isolate env vars using a small test lock if needed because env is process-global. -8. Ensure tests restore any modified `BRAVE_API_KEY` and `EXA_API_KEY` values. - -### T004: Convert CliProvider to ProviderId at the binary edge - -**Summary**: Add explicit conversion from CLI provider enum to bootstrap provider ID without making the registry depend on CLI types. - -**Owner**: backend - -**Estimate**: 30m - -**Dependencies**: T001 - -**Target milestone**: M3 - -**Acceptance test**: `cargo test cli::args::tests::test_cli_provider_parses_exa_and_defaults_to_brave` still passes. - -**Files/modules touched**: -- `src/main.rs` - -**Steps**: -1. Import `crate::bootstrap::provider_registry::ProviderId` in `src/main.rs`. -2. Add `impl From for ProviderId` in `src/main.rs`. -3. Map `CliProvider::Brave` to `ProviderId::Brave`. -4. Map `CliProvider::Exa` to `ProviderId::Exa`. -5. Keep `src/bootstrap/provider_registry.rs` free of `crate::cli` imports. - -### T005: Replace inline provider construction in main - -**Summary**: Remove the duplicated provider construction `match` from `main` and delegate service construction to the production registry. - -**Owner**: backend - -**Estimate**: 45m - -**Dependencies**: T002,T003,T004 - -**Target milestone**: M3 - -**Acceptance test**: Running `cargo run -- "rust"` with no `BRAVE_API_KEY` prints a provider-unavailable/configuration message before any search request is attempted. - -**Files/modules touched**: -- `src/main.rs` - -**Steps**: -1. Remove direct imports of `SearchService`, `BraveProvider`, `BraveConfig`, `ExaProvider`, `ExaConfig`, and `ReqwestHttpClient` from `src/main.rs` if no longer used. -2. Import `ProviderRegistry` from `src/bootstrap/provider_registry.rs`. -3. Replace the inline `let service = match args.provider { ... };` block with: - - `let provider_id = ProviderId::from(args.provider);` - - `let registry = ProviderRegistry::production_from_env();` - - `let service = registry.build(provider_id).unwrap_or_else(|error| { eprintln!("{error}"); std::process::exit(1); });` -4. Keep the existing `service.search(query).await` block unchanged. -5. Ensure missing provider config fails during service construction, not during `service.search`. - -### T006: Extend architecture guardrails for the composition layer - -**Summary**: Document the new `bootstrap` layer in architecture tests so its intentionally broad dependencies do not weaken existing boundaries. - -**Owner**: backend - -**Estimate**: 45m - -**Dependencies**: T001,T005 - -**Target milestone**: M3 - -**Acceptance test**: `cargo test --test architecture_test` passes and includes a guard that `src/bootstrap` does not import `crate::cli`. - -**Files/modules touched**: -- `tests/architecture_test.rs` - -**Steps**: -1. Add a new architecture test named `test_bootstrap_does_not_import_cli`. -2. Use the existing `check_dir_for_forbidden_patterns` helper against `src/bootstrap`. -3. Forbid `use crate::cli::` in `src/bootstrap`. -4. Leave existing tests for `domain`, `transport`, `providers`, `app`, and `render_text` unchanged. -5. Do not forbid `bootstrap` from importing `app`, `providers`, `transport`, or `domain`; it is the composition layer. - -### T007: Add focused registry tests - -**Summary**: Add unit coverage for registration, available provider ordering, and successful service construction with fake providers. - -**Owner**: backend - -**Estimate**: 1h - -**Dependencies**: T002 - -**Target milestone**: M4 - -**Acceptance test**: `cargo test bootstrap::provider_registry` passes. - -**Files/modules touched**: -- `src/bootstrap/provider_registry.rs` - -**Steps**: -1. Add a `#[cfg(test)]` module in `src/bootstrap/provider_registry.rs`. -2. Define a local `MockProvider` implementing `SearchProvider`. -3. Add a test that registers `ProviderId::Brave`, verifies `available_providers()` returns `vec![ProviderId::Brave]`, and verifies `build(ProviderId::Brave)` succeeds. -4. Add a test that registers both providers and verifies stable ordering is `vec![ProviderId::Brave, ProviderId::Exa]`. -5. Keep env-dependent tests separated from pure registry tests. - -### T008: Run final local gate and update docs only if source references require it - -**Summary**: Run the repository check gate and only update developer docs if the new bootstrap layer makes existing maps inaccurate. - -**Owner**: backend - -**Estimate**: 45m - -**Dependencies**: T005,T006,T007 - -**Target milestone**: M4 - -**Acceptance test**: `just check` passes. - -**Files/modules touched**: -- `HARNESS.md` -- `docs/src/architecture.md` - -**Steps**: -1. Run `cargo fmt`. -2. Run `cargo test`. -3. Run `cargo clippy -- -D warnings -W clippy::complexity -W clippy::cognitive_complexity`. -4. Run `mdbook build`. -5. Run `just check` as the final umbrella gate. -6. If docs mention `src/main.rs` as directly wiring concrete providers, update only the affected lines in `docs/src/architecture.md`. -7. If `HARNESS.md` source index or architecture boundary section is now inaccurate because `src/bootstrap` exists, update only those lines. - -## Risks & Mitigations - -- Risk: `HttpClient` is not object-safe because it has generic methods. - Mitigation: Do not put `Box` in the registry. Keep concrete `ReqwestHttpClient` inside provider builders. -- Risk: Env-var tests are process-global and can be flaky under parallel test execution. - Mitigation: Prefer pure registry tests. If testing `production_from_env()`, guard env mutation with a test lock and restore variables. -- Risk: Omitting unconfigured providers may make the default `brave` provider fail differently than before. - Mitigation: Use a clear `ProviderUnavailable` message that names the selected provider and lists configured providers. -- Risk: A new composition layer can become a dumping ground. - Mitigation: Restrict it to provider registration and `SearchService` construction; keep query building and rendering in `main`/`cli`. -- Risk: Architecture tests could accidentally forbid the new composition layer from doing its job. - Mitigation: Add only a `bootstrap` no-CLI guard; do not forbid imports of `app`, `providers`, `transport`, or `domain`. - -## Test Strategy - -- T002 adds one unit test for unavailable provider behavior. -- T003 adds one env-backed availability test if it can be made deterministic. -- T004 relies on the existing CLI parsing test as the acceptance proof. -- T005 uses one manual CLI acceptance proof for missing-key behavior. -- T006 adds one architecture boundary test for `src/bootstrap`. -- T007 adds one focused registry unit test for successful construction and ordering. -- T008 runs the full check gate. - -## References - -- `src/main.rs:55` current inline provider construction branch -- `src/app/search_service.rs:6` `SearchService` owns `Box` -- `src/domain/provider.rs:19` `SearchProvider` trait object boundary -- `src/transport/http.rs:6` generic `HttpClient` methods are not object-safe -- `src/providers/brave/config.rs:8` Brave env config loading -- `src/providers/exa/config.rs:8` Exa env config loading -- `tests/architecture_test.rs:24` providers cannot import CLI or app -- `.artifacts/interface-designs/search-service-flow-visual.html` visual comparison of interface options - -## Final Gate - -- **Output summary**: plan dir path, milestone count, ticket count -- **Next step**: proceed to execute-phase with `.artifacts/plan/2026-04-25_13-50-32_provider-registry/PLAN.md` diff --git a/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/INDEX.md b/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/INDEX.md deleted file mode 100644 index 005fc53..0000000 --- a/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/INDEX.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: "Ticket Index" -type: ticket_index -parent_plan: "../PLAN.md" -created_at: "2026-04-25T13:51:35Z" -tags: [ticket, plan] ---- - -# Ticket Index - -| Task | Title | Ticket | -|---|---|---| -| T001 | Add bootstrap module skeleton and ProviderId | [T001](./T001.md) | -| T002 | Define registry and construction error contract | [T002](./T002.md) | -| T003 | Add production registry that only includes configured providers | [T003](./T003.md) | -| T004 | Convert CliProvider to ProviderId at the binary edge | [T004](./T004.md) | -| T005 | Replace inline provider construction in main | [T005](./T005.md) | -| T006 | Extend architecture guardrails for the composition layer | [T006](./T006.md) | -| T007 | Add focused registry tests | [T007](./T007.md) | -| T008 | Run final local gate and update docs only if source references require it | [T008](./T008.md) | diff --git a/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T001.md b/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T001.md deleted file mode 100644 index f1c2f87..0000000 --- a/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T001.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -title: "T001: Add bootstrap module skeleton and ProviderId" -type: plan_ticket -task_id: "T001" -parent_plan: "../PLAN.md" -created_at: "2026-04-25T13:51:35Z" -tags: [ticket, plan] ---- - -# T001: Add bootstrap module skeleton and ProviderId - -**Summary**: Create the composition module and provider-neutral selection type without wiring any providers yet. - -**Owner**: backend - -**Estimate**: 45m - -**Dependencies**: - -**Target milestone**: M1 - -**Acceptance test**: `cargo check` passes with `mod bootstrap;` declared and no provider behavior changed. - -**Files/modules touched**: -- `src/main.rs` -- `src/bootstrap/mod.rs` -- `src/bootstrap/provider_registry.rs` - -**Steps**: -1. Add `mod bootstrap;` near the other module declarations in `src/main.rs`. -2. Create `src/bootstrap/mod.rs` with `pub mod provider_registry;`. -3. Create `src/bootstrap/provider_registry.rs`. -4. Define `#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ProviderId { Brave, Exa }`. -5. Implement `Display` for `ProviderId` with lowercase labels `brave` and `exa`. -6. Do not import `crate::cli` in `src/bootstrap/provider_registry.rs`. diff --git a/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T002.md b/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T002.md deleted file mode 100644 index 200b73e..0000000 --- a/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T002.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -title: "T002: Define registry and construction error contract" -type: plan_ticket -task_id: "T002" -parent_plan: "../PLAN.md" -created_at: "2026-04-25T13:51:35Z" -tags: [ticket, plan] ---- - -# T002: Define registry and construction error contract - -**Summary**: Add the provider registry type, provider builder alias, and construction errors used by production service creation. - -**Owner**: backend - -**Estimate**: 1h - -**Dependencies**: T001 - -**Target milestone**: M1 - -**Acceptance test**: Unit test constructs an empty registry and receives `ProviderUnavailable` when building `ProviderId::Brave`. - -**Files/modules touched**: -- `src/bootstrap/provider_registry.rs` - -**Steps**: -1. Import `std::collections::HashMap`, `crate::app::search_service::SearchService`, and `crate::domain::provider::SearchProvider`. -2. Define `pub type ProviderBuilder = Box Box + Send + Sync>;`. -3. Define `pub struct ProviderRegistry { builders: HashMap }`. -4. Add `pub fn empty() -> Self`. -5. Add `pub fn register(&mut self, id: ProviderId, builder: ProviderBuilder)`. -6. Add `pub fn available_providers(&self) -> Vec` that returns stable sorted order `[Brave, Exa]` when present. -7. Define `#[derive(Debug, thiserror::Error)] pub enum BuildSearchServiceError` with `ProviderUnavailable { provider: ProviderId, available: Vec }`. -8. Implement `pub fn build(&self, provider: ProviderId) -> Result` that creates `SearchService::new(builder())` when registered. -9. Add a unit test for empty registry unavailable behavior. diff --git a/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T003.md b/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T003.md deleted file mode 100644 index c091371..0000000 --- a/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T003.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -title: "T003: Add production registry that only includes configured providers" -type: plan_ticket -task_id: "T003" -parent_plan: "../PLAN.md" -created_at: "2026-04-25T13:51:35Z" -tags: [ticket, plan] ---- - -# T003: Add production registry that only includes configured providers - -**Summary**: Implement env-backed production registration where missing provider keys mean the provider is omitted from the registry. - -**Owner**: backend - -**Estimate**: 1h - -**Dependencies**: T002 - -**Target milestone**: M2 - -**Acceptance test**: Unit test sets only `BRAVE_API_KEY` and confirms `production_from_env()` lists Brave but not Exa. - -**Files/modules touched**: -- `src/bootstrap/provider_registry.rs` - -**Steps**: -1. Import `BraveProvider`, `BraveConfig`, `ExaProvider`, `ExaConfig`, and `ReqwestHttpClient`. -2. Add `pub fn production_from_env() -> Self`. -3. In `production_from_env()`, call `BraveConfig::from_env()`. If it returns `Ok(config)`, register `ProviderId::Brave` with a closure that clones `config` and returns `Box::new(BraveProvider::new(ReqwestHttpClient::new(), config.clone()))`. -4. In `production_from_env()`, call `ExaConfig::from_env()`. If it returns `Ok(config)`, register `ProviderId::Exa` with a closure that clones `config` and returns `Box::new(ExaProvider::new(ReqwestHttpClient::new(), config.clone()))`. -5. If a config load returns `Err(std::env::VarError::NotPresent)`, do not register that provider. -6. If a config load returns `Err(std::env::VarError::NotUnicode(_))`, do not register that provider for now; surface the same `ProviderUnavailable` if selected. -7. Add tests that isolate env vars using a small test lock if needed because env is process-global. -8. Ensure tests restore any modified `BRAVE_API_KEY` and `EXA_API_KEY` values. diff --git a/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T004.md b/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T004.md deleted file mode 100644 index 0ed175d..0000000 --- a/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T004.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -title: "T004: Convert CliProvider to ProviderId at the binary edge" -type: plan_ticket -task_id: "T004" -parent_plan: "../PLAN.md" -created_at: "2026-04-25T13:51:35Z" -tags: [ticket, plan] ---- - -# T004: Convert CliProvider to ProviderId at the binary edge - -**Summary**: Add explicit conversion from CLI provider enum to bootstrap provider ID without making the registry depend on CLI types. - -**Owner**: backend - -**Estimate**: 30m - -**Dependencies**: T001 - -**Target milestone**: M3 - -**Acceptance test**: `cargo test cli::args::tests::test_cli_provider_parses_exa_and_defaults_to_brave` still passes. - -**Files/modules touched**: -- `src/main.rs` - -**Steps**: -1. Import `crate::bootstrap::provider_registry::ProviderId` in `src/main.rs`. -2. Add `impl From for ProviderId` in `src/main.rs`. -3. Map `CliProvider::Brave` to `ProviderId::Brave`. -4. Map `CliProvider::Exa` to `ProviderId::Exa`. -5. Keep `src/bootstrap/provider_registry.rs` free of `crate::cli` imports. diff --git a/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T005.md b/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T005.md deleted file mode 100644 index d576817..0000000 --- a/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T005.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -title: "T005: Replace inline provider construction in main" -type: plan_ticket -task_id: "T005" -parent_plan: "../PLAN.md" -created_at: "2026-04-25T13:51:35Z" -tags: [ticket, plan] ---- - -# T005: Replace inline provider construction in main - -**Summary**: Remove the duplicated provider construction `match` from `main` and delegate service construction to the production registry. - -**Owner**: backend - -**Estimate**: 45m - -**Dependencies**: T002,T003,T004 - -**Target milestone**: M3 - -**Acceptance test**: Running `cargo run -- "rust"` with no `BRAVE_API_KEY` prints a provider-unavailable/configuration message before any search request is attempted. - -**Files/modules touched**: -- `src/main.rs` - -**Steps**: -1. Remove direct imports of `SearchService`, `BraveProvider`, `BraveConfig`, `ExaProvider`, `ExaConfig`, and `ReqwestHttpClient` from `src/main.rs` if no longer used. -2. Import `ProviderRegistry` from `src/bootstrap/provider_registry.rs`. -3. Replace the inline `let service = match args.provider { ... };` block with: - - `let provider_id = ProviderId::from(args.provider);` - - `let registry = ProviderRegistry::production_from_env();` - - `let service = registry.build(provider_id).unwrap_or_else(|error| { eprintln!("{error}"); std::process::exit(1); });` -4. Keep the existing `service.search(query).await` block unchanged. -5. Ensure missing provider config fails during service construction, not during `service.search`. diff --git a/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T006.md b/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T006.md deleted file mode 100644 index deb0d1d..0000000 --- a/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T006.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -title: "T006: Extend architecture guardrails for the composition layer" -type: plan_ticket -task_id: "T006" -parent_plan: "../PLAN.md" -created_at: "2026-04-25T13:51:35Z" -tags: [ticket, plan] ---- - -# T006: Extend architecture guardrails for the composition layer - -**Summary**: Document the new `bootstrap` layer in architecture tests so its intentionally broad dependencies do not weaken existing boundaries. - -**Owner**: backend - -**Estimate**: 45m - -**Dependencies**: T001,T005 - -**Target milestone**: M3 - -**Acceptance test**: `cargo test --test architecture_test` passes and includes a guard that `src/bootstrap` does not import `crate::cli`. - -**Files/modules touched**: -- `tests/architecture_test.rs` - -**Steps**: -1. Add a new architecture test named `test_bootstrap_does_not_import_cli`. -2. Use the existing `check_dir_for_forbidden_patterns` helper against `src/bootstrap`. -3. Forbid `use crate::cli::` in `src/bootstrap`. -4. Leave existing tests for `domain`, `transport`, `providers`, `app`, and `render_text` unchanged. -5. Do not forbid `bootstrap` from importing `app`, `providers`, `transport`, or `domain`; it is the composition layer. diff --git a/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T007.md b/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T007.md deleted file mode 100644 index 319ed38..0000000 --- a/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T007.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -title: "T007: Add focused registry tests" -type: plan_ticket -task_id: "T007" -parent_plan: "../PLAN.md" -created_at: "2026-04-25T13:51:35Z" -tags: [ticket, plan] ---- - -# T007: Add focused registry tests - -**Summary**: Add unit coverage for registration, available provider ordering, and successful service construction with fake providers. - -**Owner**: backend - -**Estimate**: 1h - -**Dependencies**: T002 - -**Target milestone**: M4 - -**Acceptance test**: `cargo test bootstrap::provider_registry` passes. - -**Files/modules touched**: -- `src/bootstrap/provider_registry.rs` - -**Steps**: -1. Add a `#[cfg(test)]` module in `src/bootstrap/provider_registry.rs`. -2. Define a local `MockProvider` implementing `SearchProvider`. -3. Add a test that registers `ProviderId::Brave`, verifies `available_providers()` returns `vec![ProviderId::Brave]`, and verifies `build(ProviderId::Brave)` succeeds. -4. Add a test that registers both providers and verifies stable ordering is `vec![ProviderId::Brave, ProviderId::Exa]`. -5. Keep env-dependent tests separated from pure registry tests. diff --git a/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T008.md b/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T008.md deleted file mode 100644 index 77afae9..0000000 --- a/.artifacts/plan/2026-04-25_13-50-32_provider-registry/tickets/T008.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -title: "T008: Run final local gate and update docs only if source references require it" -type: plan_ticket -task_id: "T008" -parent_plan: "../PLAN.md" -created_at: "2026-04-25T13:51:35Z" -tags: [ticket, plan] ---- - -# T008: Run final local gate and update docs only if source references require it - -**Summary**: Run the repository check gate and only update developer docs if the new bootstrap layer makes existing maps inaccurate. - -**Owner**: backend - -**Estimate**: 45m - -**Dependencies**: T005,T006,T007 - -**Target milestone**: M4 - -**Acceptance test**: `just check` passes. - -**Files/modules touched**: -- `HARNESS.md` -- `docs/src/architecture.md` - -**Steps**: -1. Run `cargo fmt`. -2. Run `cargo test`. -3. Run `cargo clippy -- -D warnings -W clippy::complexity -W clippy::cognitive_complexity`. -4. Run `mdbook build`. -5. Run `just check` as the final umbrella gate. -6. If docs mention `src/main.rs` as directly wiring concrete providers, update only the affected lines in `docs/src/architecture.md`. -7. If `HARNESS.md` source index or architecture boundary section is now inaccurate because `src/bootstrap` exists, update only those lines. - -## Risks & Mitigations - -- Risk: `HttpClient` is not object-safe because it has generic methods. - Mitigation: Do not put `Box` in the registry. Keep concrete `ReqwestHttpClient` inside provider builders. -- Risk: Env-var tests are process-global and can be flaky under parallel test execution. - Mitigation: Prefer pure registry tests. If testing `production_from_env()`, guard env mutation with a test lock and restore variables. -- Risk: Omitting unconfigured providers may make the default `brave` provider fail differently than before. - Mitigation: Use a clear `ProviderUnavailable` message that names the selected provider and lists configured providers. -- Risk: A new composition layer can become a dumping ground. - Mitigation: Restrict it to provider registration and `SearchService` construction; keep query building and rendering in `main`/`cli`. -- Risk: Architecture tests could accidentally forbid the new composition layer from doing its job. - Mitigation: Add only a `bootstrap` no-CLI guard; do not forbid imports of `app`, `providers`, `transport`, or `domain`. - -## Test Strategy - -- T002 adds one unit test for unavailable provider behavior. -- T003 adds one env-backed availability test if it can be made deterministic. -- T004 relies on the existing CLI parsing test as the acceptance proof. -- T005 uses one manual CLI acceptance proof for missing-key behavior. -- T006 adds one architecture boundary test for `src/bootstrap`. -- T007 adds one focused registry unit test for successful construction and ordering. -- T008 runs the full check gate. - -## References - -- `src/main.rs:55` current inline provider construction branch -- `src/app/search_service.rs:6` `SearchService` owns `Box` -- `src/domain/provider.rs:19` `SearchProvider` trait object boundary -- `src/transport/http.rs:6` generic `HttpClient` methods are not object-safe -- `src/providers/brave/config.rs:8` Brave env config loading -- `src/providers/exa/config.rs:8` Exa env config loading -- `tests/architecture_test.rs:24` providers cannot import CLI or app -- `.artifacts/interface-designs/search-service-flow-visual.html` visual comparison of interface options - -## Final Gate - -- **Output summary**: plan dir path, milestone count, ticket count -- **Next step**: proceed to execute-phase with `.artifacts/plan/2026-04-25_13-50-32_provider-registry/PLAN.md` diff --git a/.artifacts/research/2026-04-15_21-10-50_provider-integration-map.md b/.artifacts/research/2026-04-15_21-10-50_provider-integration-map.md deleted file mode 100644 index ee44cd3..0000000 --- a/.artifacts/research/2026-04-15_21-10-50_provider-integration-map.md +++ /dev/null @@ -1,125 +0,0 @@ ---- -title: "provider integration map research findings" -link: "provider-integration-map-research" -type: research -ontological_relations: - - relates_to: [[search-cli-plan]] -tags: [research, provider, rust, search-cli] -uuid: "d328abd9-9e8f-40a1-af87-65be2ac78dd3" -created_at: "2026-04-15T21:10:50Z" ---- - -## Structure -- `src/domain/` contains provider-agnostic query, result, error, and trait types. -- `src/transport/` contains the HTTP abstraction (`HttpClient`) and the reqwest adapter. -- `src/providers/brave/` contains the Brave adapter split into config, DTO, mapper, and client modules. -- `src/app/` contains `SearchService`, which stores `Box`. -- `src/cli/` contains CLI argument parsing and text rendering. -- `src/main.rs` composes the concrete provider and converts CLI args into a `SearchQuery`. - -## Key Files -- `src/domain/provider.rs:6` defines `ProviderCapabilities`. -- `src/domain/provider.rs:19` defines the `SearchProvider` trait with `id`, `capabilities`, and `search`. -- `src/domain/query.rs:4` defines `SearchQuery`. -- `src/domain/result.rs:5` defines `SearchResponse` with the provider identifier recorded as `String`. -- `src/domain/types.rs:2` defines supported `SearchType` variants: `Web`, `News`, `Images`, `Videos`. -- `src/transport/http.rs:5` defines the `HttpClient` trait used by provider clients. -- `src/transport/http.rs:18` defines `ReqwestHttpClient`. -- `src/providers/mod.rs:1` exports only the `brave` provider module. -- `src/providers/brave/config.rs:2` defines `BraveConfig`. -- `src/providers/brave/config.rs:8` loads configuration from `BRAVE_API_KEY`. -- `src/providers/brave/dto.rs:4` defines `BraveWebResponse`. -- `src/providers/brave/dto.rs:30` defines `BraveNewsResponse`. -- `src/providers/brave/dto.rs:51` defines `BraveImagesResponse`. -- `src/providers/brave/dto.rs:71` defines `BraveVideosResponse`. -- `src/providers/brave/mapper.rs:4` maps `BraveWebResponse` into `SearchResponse`. -- `src/providers/brave/mapper.rs:27` maps `BraveNewsResponse` into `SearchResponse`. -- `src/providers/brave/mapper.rs:50` maps `BraveImagesResponse` into `SearchResponse`. -- `src/providers/brave/mapper.rs:72` maps `BraveVideosResponse` into `SearchResponse`. -- `src/providers/brave/client.rs:12` defines `BraveProvider`. -- `src/providers/brave/client.rs:24` implements `SearchProvider` for `BraveProvider`. -- `src/app/search_service.rs:6` defines `SearchService`. -- `src/app/search_service.rs:11` constructs `SearchService` with `Box`. -- `src/main.rs:12` imports `BraveProvider`. -- `src/main.rs:13` imports `BraveConfig`. -- `src/main.rs:42` loads Brave config. -- `src/main.rs:51` constructs the concrete `BraveProvider`. -- `src/main.rs:52` injects the provider into `SearchService`. -- `src/cli/args.rs:40` defines `CliArgs`. -- `tests/architecture_test.rs:25` enforces that provider modules do not import `cli` or `app`. - -## Patterns Found -- Provider abstraction: - - `src/domain/provider.rs:19` - - `src/app/search_service.rs:7` - - `src/app/search_service.rs:11` - - `src/app/search_service.rs:32` - - `src/providers/brave/client.rs:24` -- Provider-specific DTO to domain mapping: - - `src/providers/brave/mapper.rs:4` - - `src/providers/brave/mapper.rs:27` - - `src/providers/brave/mapper.rs:50` - - `src/providers/brave/mapper.rs:72` -- Provider-specific runtime composition in the binary: - - `src/main.rs:12` - - `src/main.rs:13` - - `src/main.rs:42` - - `src/main.rs:51` - - `src/main.rs:52` -- Provider identifier stored in normalized output: - - `src/domain/result.rs:7` - - `src/providers/brave/mapper.rs:10` - - `src/providers/brave/mapper.rs:32` - - `src/providers/brave/mapper.rs:55` - - `src/providers/brave/mapper.rs:77` - - `src/cli/output.rs:5` - -## Dependencies -- `src/main.rs` imports: - - `src/app/search_service.rs` - - `src/cli/args.rs` - - `src/cli/output.rs` - - `src/domain/query.rs` - - `src/providers/brave/client.rs` - - `src/providers/brave/config.rs` - - `src/transport/http.rs` -- `src/app/search_service.rs` imports: - - `src/domain/error.rs` - - `src/domain/provider.rs` - - `src/domain/query.rs` - - `src/domain/result.rs` -- `src/providers/brave/client.rs` imports: - - `src/domain/error.rs` - - `src/domain/provider.rs` - - `src/domain/query.rs` - - `src/domain/result.rs` - - `src/domain/types.rs` - - `src/providers/brave/config.rs` - - `src/providers/brave/dto.rs` - - `src/providers/brave/mapper.rs` - - `src/transport/http.rs` -- `src/providers/brave/mapper.rs` imports: - - `src/domain/result.rs` - - `src/providers/brave/dto.rs` -- `src/transport/http.rs` imports: - - `src/domain/error.rs` - -## Provider Addition Surface -- New provider modules belong under `src/providers/`, based on the existing `src/providers/brave/` layout and `src/providers/mod.rs:1`. -- The required trait implementation boundary is `src/domain/provider.rs:19`. -- The application layer already accepts any boxed provider at `src/app/search_service.rs:7` and `src/app/search_service.rs:11`. -- The current binary selects Brave directly in `src/main.rs:42`, `src/main.rs:51`, and `src/main.rs:52`. -- The current CLI arguments do not include a provider selector; `src/cli/args.rs:43-66` contains query, about flag, search type, limit, offset, safe search, country, and language. - -## Tests And Enforcement -- `src/providers/brave/mapper.rs:95` contains mapper unit tests for all four search types. -- `src/providers/brave/client.rs:110` contains a provider test using a mocked `HttpClient`. -- `src/app/search_service.rs:20` contains a service test using a mocked `SearchProvider`. -- `tests/architecture_test.rs:25` forbids provider imports from `crate::cli::` and `crate::app::`. -- `justfile:2` defines `just check`, which runs format, clippy, tests, and mdBook build. - -## Operator Artifacts -- `docs/architecture.md:151` documents provider addition as creating a new provider module, implementing `SearchProvider`, and mapping DTOs into `SearchResponse`. -- `docs/architecture.md:155` states that no changes to `domain`, `app`, or `cli` are required for a new provider. -- `src/main.rs:12-13` and `src/main.rs:42-52` show Brave-specific composition in the binary. -- `PRD.md` was not present in the repository root during this scan. diff --git a/.artifacts/research/2026-04-15_21-19-47_exa-provider-contract-map.md b/.artifacts/research/2026-04-15_21-19-47_exa-provider-contract-map.md deleted file mode 100644 index 7d938a5..0000000 --- a/.artifacts/research/2026-04-15_21-19-47_exa-provider-contract-map.md +++ /dev/null @@ -1,203 +0,0 @@ ---- -title: "exa provider contract map research findings" -link: "exa-provider-contract-map-research" -type: research -ontological_relations: - - relates_to: [[search-cli-plan]] -tags: [research, exa, provider, rust, search-cli] -uuid: "8002473b-28c6-40a7-8ac3-3bf9b7c9480c" -created_at: "2026-04-15T21:19:47Z" ---- - -## Structure -- `src/domain/` contains the provider-agnostic query, result, type, and provider trait definitions. -- `src/transport/` contains the HTTP abstraction and reqwest adapter. -- `src/providers/brave/` contains the only current provider implementation. -- `src/main.rs` constructs `BraveProvider` directly and injects it into `SearchService`. -- `src/cli/args.rs` exposes query, search type, limit, offset, safe search, country, and language flags. -- Exa documentation referenced in this scan: - - `https://exa.ai/docs/reference/search-api-guide` - - `https://exa.ai/docs/reference/search` - - `https://exa.ai/docs/reference/search-api-guide-for-coding-agents` - -## Key Files -- `src/domain/types.rs:2` defines `SearchType::{Web, News, Images, Videos}`. -- `src/domain/types.rs:10` defines `SafeSearch::{Off, Moderate, Strict}`. -- `src/domain/types.rs:18` defines `TimeRange::{Day, Week, Month, Year}`. -- `src/domain/query.rs:4` defines `SearchQuery { text, search_type, limit, offset, safe_search, country, language, time_range }`. -- `src/domain/result.rs:5` defines `SearchResponse { query, provider, results, total_estimated, next_page }`. -- `src/domain/result.rs:14` defines `SearchResult::{Web, News, Image, Video}`. -- `src/domain/provider.rs:8` defines `ProviderCapabilities { web, news, images, videos, pagination, safe_search, time_range_filter }`. -- `src/domain/provider.rs:19` defines the `SearchProvider` trait. -- `src/transport/http.rs:5` defines `HttpClient::get_json`. -- `src/transport/http.rs:18` defines `ReqwestHttpClient`. -- `src/providers/mod.rs:1` exports only `brave`. -- `src/providers/brave/config.rs:2` defines `BraveConfig`. -- `src/providers/brave/client.rs:12` defines `BraveProvider`. -- `src/providers/brave/client.rs:24` implements `SearchProvider` for `BraveProvider`. -- `src/providers/brave/mapper.rs:4` maps Brave web DTOs into `SearchResponse`. -- `src/providers/brave/mapper.rs:27` maps Brave news DTOs into `SearchResponse`. -- `src/providers/brave/mapper.rs:50` maps Brave image DTOs into `SearchResponse`. -- `src/providers/brave/mapper.rs:72` maps Brave video DTOs into `SearchResponse`. -- `src/cli/args.rs:43` defines `CliArgs`. -- `src/main.rs:12` imports `BraveProvider`. -- `src/main.rs:13` imports `BraveConfig`. -- `src/main.rs:42` loads Brave config from env. -- `src/main.rs:50` constructs `ReqwestHttpClient`. -- `src/main.rs:51` constructs `BraveProvider`. -- `src/main.rs:52` injects the provider into `SearchService`. - -## Patterns Found -- Shared provider contract: - - `src/domain/provider.rs:19` - - `src/app/search_service.rs:7` - - `src/app/search_service.rs:11` - - `src/providers/brave/client.rs:24` -- Query-to-provider request translation: - - `src/providers/brave/client.rs:41` - - `src/providers/brave/client.rs:50` - - `src/providers/brave/client.rs:57` - - `src/providers/brave/client.rs:65` - - `src/providers/brave/client.rs:68` - - `src/providers/brave/client.rs:71` -- Provider DTO-to-domain result mapping: - - `src/providers/brave/mapper.rs:4` - - `src/providers/brave/mapper.rs:27` - - `src/providers/brave/mapper.rs:50` - - `src/providers/brave/mapper.rs:72` -- Provider selection in the binary: - - `src/main.rs:42` - - `src/main.rs:51` - - `src/main.rs:52` - -## Dependencies -- `src/main.rs` imports: - - `src/app/search_service.rs` - - `src/cli/args.rs` - - `src/cli/output.rs` - - `src/domain/query.rs` - - `src/providers/brave/client.rs` - - `src/providers/brave/config.rs` - - `src/transport/http.rs` -- `src/providers/brave/client.rs` imports: - - `src/domain/error.rs` - - `src/domain/provider.rs` - - `src/domain/query.rs` - - `src/domain/result.rs` - - `src/domain/types.rs` - - `src/providers/brave/config.rs` - - `src/providers/brave/dto.rs` - - `src/providers/brave/mapper.rs` - - `src/transport/http.rs` -- `src/providers/brave/mapper.rs` imports: - - `src/domain/result.rs` - - `src/providers/brave/dto.rs` - -## Current Shared Contract Surface -- `SearchQuery.search_type` uses the shared enum in `src/domain/types.rs:2`. -- `SearchQuery.limit` and `SearchQuery.offset` are optional numeric fields in `src/domain/query.rs:7-8`. -- `SearchQuery.safe_search`, `country`, `language`, and `time_range` are optional fields in `src/domain/query.rs:9-12`. -- `SearchResponse.results` is a vector of `SearchResult` variants in `src/domain/result.rs:8` and `src/domain/result.rs:14-18`. -- `SearchResponse.total_estimated` is optional in `src/domain/result.rs:9`. -- `SearchResponse.next_page` is optional in `src/domain/result.rs:10`. - -## Exa Request Fields Observed -- Exa search endpoint: `POST https://api.exa.ai/search`. - - Source: `https://exa.ai/docs/reference/search-api-guide-for-coding-agents` -- Exa authentication header: `x-api-key`. - - Source: `https://exa.ai/docs/reference/search-api-guide-for-coding-agents` -- Request parameters listed in the Exa coding-agent reference: - - `query` - - `type` - - `stream` - - `numResults` - - `category` - - `userLocation` - - `includeDomains` - - `excludeDomains` - - `startPublishedDate` - - `endPublishedDate` - - `startCrawlDate` - - `endCrawlDate` - - `moderation` - - `additionalQueries` - - `systemPrompt` - - `outputSchema` - - Source: `https://exa.ai/docs/reference/search-api-guide-for-coding-agents` -- Exa contents parameters listed in the Exa coding-agent reference: - - `contents.text` - - `contents.highlights` - - `contents.summary` - - `contents.livecrawlTimeout` - - `contents.maxAgeHours` - - `contents.subpages` - - `contents.subpageTarget` - - `contents.extras.links` - - `contents.extras.imageLinks` - - Source: `https://exa.ai/docs/reference/search-api-guide-for-coding-agents` -- Exa search types listed in the Exa coding-agent reference: - - `auto` - - `fast` - - `instant` - - `deep-lite` - - `deep` - - `deep-reasoning` - - Source: `https://exa.ai/docs/reference/search-api-guide-for-coding-agents` -- Exa categories listed in the Exa coding-agent reference: - - `company` - - `people` - - `research paper` - - `news` - - `personal site` - - `financial report` - - Source: `https://exa.ai/docs/reference/search-api-guide-for-coding-agents` - -## Exa Response Fields Observed -- Exa response top-level fields listed in the Exa coding-agent reference: - - `requestId` - - `searchType` - - `results` - - `output` - - `costDollars` - - Source: `https://exa.ai/docs/reference/search-api-guide-for-coding-agents` -- Exa result object fields listed in the Exa coding-agent reference: - - `title` - - `url` - - `id` - - `publishedDate` - - `author` - - `image` - - `favicon` - - `text` - - `highlights` - - `highlightScores` - - `summary` - - `subpages` - - `extras.links` - - Source: `https://exa.ai/docs/reference/search-api-guide-for-coding-agents` - -## Repo-to-Exa Field Presence Map -- `src/domain/query.rs:5` defines `text`; Exa request docs list `query`. -- `src/domain/query.rs:6` defines `search_type`; Exa request docs list `type` and `category`. -- `src/domain/query.rs:7` defines `limit`; Exa request docs list `numResults`. -- `src/domain/query.rs:8` defines `offset`; no Exa request field named `offset` appears in the referenced Exa docs. -- `src/domain/query.rs:9` defines `safe_search`; Exa request docs list `moderation`. -- `src/domain/query.rs:10` defines `country`; Exa request docs list `userLocation`. -- `src/domain/query.rs:11` defines `language`; no Exa request field named `language` appears in the referenced Exa docs. -- `src/domain/query.rs:12` defines `time_range`; Exa request docs list `startPublishedDate`, `endPublishedDate`, `startCrawlDate`, and `endCrawlDate`. -- `src/domain/result.rs:22-27` defines `WebResult`. -- `src/domain/result.rs:30-36` defines `NewsResult`. -- `src/domain/result.rs:39-44` defines `ImageResult`. -- `src/domain/result.rs:47-53` defines `VideoResult`. -- Exa result docs list `title`, `url`, `publishedDate`, `author`, `image`, `favicon`, `text`, `highlights`, `summary`, `subpages`, and `extras.links`. - -## Current Transport Surface Compared To Exa Docs -- `src/transport/http.rs:6-10` defines `HttpClient::get_json` with `url`, `headers`, and query parameters. -- `src/transport/http.rs:41` builds a reqwest `GET` request. -- Exa docs referenced in this scan describe `POST /search` with a JSON request body. - -## Existing Provider Composition Surface -- `src/providers/mod.rs:1` exposes only the `brave` module. -- `src/main.rs:12-13` imports Brave-specific provider types. -- `src/main.rs:42-52` constructs Brave-specific runtime configuration and provider instances. -- `src/cli/args.rs:50-66` defines CLI flags for `search_type`, `limit`, `offset`, `safe_search`, `country`, and `language`. diff --git a/.cargo-husky/hooks/pre-push b/.cargo-husky/hooks/pre-push new file mode 100755 index 0000000..bfdcfa9 --- /dev/null +++ b/.cargo-husky/hooks/pre-push @@ -0,0 +1,5 @@ +#!/bin/sh +set -e + +echo "+ just check" +just check diff --git a/.factory/skills/sophon-cli/SKILL.md b/.factory/skills/sophon-cli/SKILL.md index 56789d9..2b44696 100644 --- a/.factory/skills/sophon-cli/SKILL.md +++ b/.factory/skills/sophon-cli/SKILL.md @@ -1,6 +1,16 @@ --- name: sophon-cli description: Rust CLI for provider-agnostic web search using Brave Search or Exa APIs + +title: "sophon-cli Agent Skill" +when_to_read: + - "When using or maintaining the repository-specific Codex skill for sophon-cli." + - "When aligning local agent behavior with this project’s commands and boundaries." +summary: "Repository-specific Codex skill instructions for sophon-cli, capturing project commands, validation expectations, and operational guardrails." +ontology_relations: + - relation: "guides" + target: "agent-workflows" + note: "Defines agent behavior for this repository." --- # sophon-cli Agent Skill diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index da96619..2ceacfb 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -4,6 +4,15 @@ about: Report a bug or unexpected behavior title: "[BUG] " labels: ["bug", "triage"] assignees: "" + +when_to_read: + - "When filing or maintaining GitHub issue templates for this repository." + - "When changing the triage questions expected from contributors." +summary: "GitHub issue template used to collect structured contributor input for Bug Report." +ontology_relations: + - relation: "supports" + target: "github-issue-triage" + note: "Shapes incoming issue reports for maintainers." --- ## Describe the bug diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 95b0c87..45156f2 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -4,6 +4,15 @@ about: Suggest an enhancement or new provider title: "[FEATURE] " labels: ["feature", "triage"] assignees: "" + +when_to_read: + - "When filing or maintaining GitHub issue templates for this repository." + - "When changing the triage questions expected from contributors." +summary: "GitHub issue template used to collect structured contributor input for Feature Request." +ontology_relations: + - relation: "supports" + target: "github-issue-triage" + note: "Shapes incoming issue reports for maintainers." --- ## Problem / Motivation diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 1c7d846..4e8bbca 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,3 +1,15 @@ +--- +title: "Pull Request Template" +when_to_read: + - "When opening or maintaining pull request guidance for contributors." + - "When changing review, validation, or documentation expectations for proposed changes." +summary: "GitHub pull request template that prompts contributors for change scope, validation evidence, and reviewer context." +ontology_relations: + - relation: "supports" + target: "github-pr-review" + note: "Guides pull request authors and reviewers." +--- + ## Description diff --git a/.github/workflows/validate-agents.yml b/.github/workflows/validate-agents.yml index aa903ca..97bd1b7 100644 --- a/.github/workflows/validate-agents.yml +++ b/.github/workflows/validate-agents.yml @@ -36,10 +36,10 @@ jobs: "src/cli/output.rs" "tests/architecture_test.rs" "docs" + "README.md" "justfile" "Cargo.toml" "HARNESS.md" - ".artifacts/plan/2026-04-14_search-cli/PLAN.md" ) for path in "${paths[@]}"; do @@ -66,18 +66,5 @@ jobs: with: tool: just,mdbook - - name: Verify canonical commands work - run: | - echo "Checking cargo fmt --check..." - cargo fmt --check - - echo "Checking cargo clippy..." - cargo clippy -- -D warnings -W clippy::complexity -W clippy::cognitive_complexity - - echo "Checking cargo test..." - cargo test - - echo "Checking mdbook build..." - mdbook build - - echo "All canonical commands validated." + - name: Verify canonical command works + run: just check diff --git a/.gitignore b/.gitignore index 8aa3e25..d93ac60 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,8 @@ .env +.artifacts/ # Added by cargo /target book/ - diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index c8ecba8..0000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,17 +0,0 @@ -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 - hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - - id: check-yaml - - id: check-added-large-files - args: ["--maxkb=500"] - - repo: local - hooks: - - id: just-check - name: just check - entry: just check - language: system - pass_filenames: false - always_run: true diff --git a/AGENTS.md b/AGENTS.md index 93a3123..406dce9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,8 +5,8 @@ - Provider-agnostic domain layer with a Brave-specific adapter behind a trait boundary. ## Where To Start -- Implementation plan: `.artifacts/plan/2026-04-14_search-cli/PLAN.md` - Harness map (checks, tests, gaps): `HARNESS.md` +- User-facing docs: `README.md` and `docs/` ## Repository Map - `src/main.rs` — CLI entrypoint; wires `cli::args`, `app::search_service`, `providers::brave`, and `transport::http` diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a2f09f..7ac763b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +--- +title: "Changelog" +when_to_read: + - "When reviewing user-visible changes across releases or unreleased work." + - "When preparing release notes or checking recent provider and CLI behavior changes." +summary: "Chronological project change log for sophon-cli, used to track additions, fixes, and behavior changes that matter to operators and users." +ontology_relations: + - relation: "records" + target: "sophon-cli-release-history" + note: "Tracks release-facing changes for the CLI." +--- + # Changelog All notable changes to this project are documented in this file. @@ -10,14 +22,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Bootstrap**: Provider registry (`src/bootstrap/provider_registry.rs`) for compile-time provider registration and metadata discovery. -- **Plans**: Complete provider registry implementation plan with tickets T001–T008 under `.artifacts/plan/2026-04-25_13-50-32_provider-registry/`. -- **Interface Designs**: Visual HTML documentation for search service flow and interface options under `.artifacts/interface-designs/`. +- **Docs Guard**: Markdown frontmatter validator and Cargo-managed pre-push hook for the canonical `just check` gate. ### Changed - **Main**: Refactored to use provider registry for provider instantiation instead of direct constructor calls. - **Architecture Tests**: Updated to allow `bootstrap` module imports from `main.rs`. - **HARNESS.md**: Updated harness map to reflect current validation chain. +- **Artifacts**: `.artifacts/` is now ignored and no longer tracked in Git. - **Exa**: Default `/search` `contents` now requests **highlights** (with `maxCharacters` and the user query) and a **query-scoped summary** object instead of full-page **`text`**, so the API is not asked for article bodies for normal CLI usage. - **Exa**: Normalized `snippet` is derived as **summary** (trimmed, capped) if non-empty, else **joined highlights** (separator ` … `, capped); **`text` is never used** as a snippet fallback, even when present in the response. diff --git a/Cargo.lock b/Cargo.lock index 56408f2..17f5399 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -123,6 +123,12 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cargo-husky" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b02b629252fe8ef6460461409564e2c21d0c8e77e0944f3d189ff06c4e932ad" + [[package]] name = "cc" version = "1.2.60" @@ -1257,6 +1263,7 @@ name = "sophon-cli" version = "0.1.0" dependencies = [ "async-trait", + "cargo-husky", "chrono", "clap", "dotenvy", diff --git a/Cargo.toml b/Cargo.toml index 3b651c8..370df1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,3 +21,8 @@ dotenvy = "0.15" chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[dev-dependencies.cargo-husky] +version = "1.5.0" +default-features = false +features = ["user-hooks"] diff --git a/HARNESS.md b/HARNESS.md index dd62ddc..0f3d23b 100644 --- a/HARNESS.md +++ b/HARNESS.md @@ -4,6 +4,15 @@ phase: Research date: "2026-04-14" owner: "agent" tags: [research, harness, sophon-cli, rust] + +when_to_read: + - "When validating local checks, architecture gates, tests, docs builds, or CI coverage." + - "When changing the repository harness or deciding which command proves the repo is healthy." +summary: "Harness map for sophon-cli, describing the canonical check command, test layers, architecture boundaries, documentation gates, and known validation gaps." +ontology_relations: + - relation: "defines" + target: "repository-harness" + note: "Documents the checks that protect changes in this repository." --- # sophon-cli – Harness Map @@ -12,11 +21,12 @@ A living map of the mechanical checks, policies, workflows, and artifacts that m ## Canonical Entry Point -- `justfile:1-5` defines the `check` recipe: +- `justfile:1-6` defines the `check` recipe: 1. `cargo fmt --check` 2. `cargo clippy -- -D warnings -W clippy::complexity -W clippy::cognitive_complexity` 3. `cargo test` - 4. `mdbook build` + 4. `python3 scripts/check_markdown_frontmatter.py` + 5. `mdbook build` There is no Makefile, npm script, or other local entrypoint. `just check` is the canonical umbrella command. @@ -28,6 +38,7 @@ There is no Makefile, npm script, or other local entrypoint. `just check` is the | Format | `cargo fmt --check` | `Cargo.toml` edition 2024 | Rust style consistency | | Lint / complexity | `cargo clippy -- -D warnings -W clippy::complexity -W clippy::cognitive_complexity` | `Cargo.toml` | Correctness + complexity ceiling | | Tests | `cargo test` | `Cargo.toml` | Behavioral verification | +| Docs metadata | `python3 scripts/check_markdown_frontmatter.py` | `scripts/check_markdown_frontmatter.py` | Required Markdown frontmatter except `AGENTS.md`, `README.md`, and `docs/SUMMARY.md` | | Docs build | `mdbook build` | `book.toml` | Documentation compiles | ### Layer 2: Architecture Boundaries @@ -60,28 +71,34 @@ No snapshot, golden, or integration test suites exist. ### Layer 5: Docs Ratchet | Check | Command | Allowlist | Notes | |-------|---------|-----------|-------| -| Docs build | `mdbook build` | n/a | Fails if markdown or `book.toml` is malformed | +| Frontmatter | `python3 scripts/check_markdown_frontmatter.py` | `AGENTS.md`, `README.md`, `docs/SUMMARY.md` | Requires `title`, `when_to_read`, `summary`, and structured `ontology_relations` | +| Docs build | `mdbook build` | n/a | Fails if markdown or `book.toml` is malformed; `scripts/mdbook_strip_frontmatter.py` strips metadata from rendered HTML | + +No link checker or nav check is configured. -No link checker, frontmatter validator, or nav check is configured. +### Layer 5.5: Git Hooks +| Hook | Source | Installed By | Runs | +|------|--------|--------------|------| +| pre-push | `.cargo-husky/hooks/pre-push` | `cargo-husky` dev dependency during `cargo test` | `just check` | ### Layer 6: CI Matrix -No CI is currently configured. There is no `.github/workflows/`, `.gitlab-ci.yml`, or equivalent. +| Workflow | Trigger | Checks | +|----------|---------|--------| +| `.github/workflows/validate-agents.yml` | pull requests and pushes to `main` | Verifies key `AGENTS.md` referenced paths exist, installs `just`/`mdbook`, and runs `just check` | ### Layer 7: Evidence Workflow -| Artifact | Location | Triggers | Format | -|----------|----------|----------|--------| -| Plan | `.artifacts/plan/2026-04-14_search-cli/PLAN.md` | Manual (plan-phase) | Markdown | -| Tickets | `.artifacts/plan/2026-04-14_search-cli/tickets/T*.md` | Plan decomposition | Markdown | -| Execution log | `.artifacts/execute/2026-04-14_search-cli.md` | Per-task updates | Markdown | +| Artifact | Location | Tracking | Notes | +|----------|----------|----------|-------| +| Local agent artifacts | `.artifacts/` | Ignored by Git | Research, planning, execution logs, and generated design notes may exist locally but are not repository sources of truth | -These are human-maintained research/execution artifacts, not mechanically enforced. +These are human-maintained local artifacts, not mechanically enforced. ### Layer 8: Operator Surface | Surface | Location | Purpose | Usage | |---------|----------|---------|-------| -| PRD | `PRD.md` | Product requirements & design rules | Read before implementing | -| Plan | `.artifacts/plan/2026-04-14_search-cli/PLAN.md` | Implementation plan | Execute-phase reference | -| Execution log | `.artifacts/execute/2026-04-14_search-cli.md` | Debug history & task status | Update after each ticket | +| README | `README.md` | User-facing package overview | Read for installation, configuration, and CLI examples | +| mdBook docs | `docs/` | Maintainer and user documentation | Read for architecture and quickstart details | +| Harness map | `HARNESS.md` | Checks, hooks, and validation chain | Read before changing repository gates | `AGENTS.md` exists at the repository root. There is no `.codex/` directory inside the repo. @@ -91,29 +108,31 @@ Ordered list of checks as executed by the canonical entry point: 1. `cargo fmt --check` 2. `cargo clippy -- -D warnings -W clippy::complexity -W clippy::cognitive_complexity` 3. `cargo test` -4. `mdbook build` +4. `python3 scripts/check_markdown_frontmatter.py` +5. `mdbook build` ## Quick Reference - **Run all local checks:** `just check` - **Run tests only:** `cargo test` - **Run lint only:** `cargo clippy -- -D warnings -W clippy::complexity -W clippy::cognitive_complexity` - **Run formatter check only:** `cargo fmt --check` -- **Run CI locally:** Not applicable (no CI configured) +- **Run CI locally:** `just check` covers the workflow's canonical command; path existence checks are in `.github/workflows/validate-agents.yml` - **Add a new check:** Edit `justfile` and append to the `check` recipe ## Source Index | File | What It Contributes | |------|---------------------| -| `justfile:1-5` | Canonical local check gate | +| `justfile:1-6` | Canonical local check gate | +| `.cargo-husky/hooks/pre-push` | Cargo-managed pre-push hook that runs `just check` | +| `scripts/check_markdown_frontmatter.py` | Markdown frontmatter and ontology relation validator | +| `scripts/mdbook_strip_frontmatter.py` | mdBook preprocessor that keeps metadata out of rendered HTML | | `AGENTS.md` | Operator-facing navigational map | +| `.github/workflows/validate-agents.yml` | Pull-request AGENTS path and canonical harness check | | `docs/` | mdBook source: intro, architecture, quickstart | | `book.toml` | mdBook configuration | -| `Cargo.toml` | Project manifest, dependencies, edition 2024 | +| `Cargo.toml` | Project manifest, dependencies, edition 2024, cargo-husky hook installer | | `src/bootstrap/provider_registry.rs` | Built-in provider registry and service construction tests | | `src/providers/brave/mapper.rs` | 4 unit tests for DTO→domain mapping | | `src/providers/brave/client.rs` | 1 mock-HTTP unit test for Brave provider | | `src/app/search_service.rs` | 1 mock-provider unit test for SearchService | | `src/cli/output.rs` | 1 unit test for text output rendering | -| `.artifacts/plan/2026-04-14_search-cli/PLAN.md` | Implementation plan (evidence) | -| `.artifacts/execute/2026-04-14_search-cli.md` | Execution log (evidence) | -| `PRD.md` | Product requirements & operator guidance | diff --git a/book.toml b/book.toml index 48b255c..4cba0bb 100644 --- a/book.toml +++ b/book.toml @@ -4,4 +4,8 @@ language = "en" src = "docs" title = "sophon-cli Documentation" +[preprocessor.strip-frontmatter] +command = "python3 scripts/mdbook_strip_frontmatter.py" +renderer = ["html"] + [output.html] diff --git a/docs/architecture.md b/docs/architecture.md index d8419fe..fccb455 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,3 +1,15 @@ +--- +title: "Architecture" +when_to_read: + - "When reading or editing the mdBook documentation surface." + - "When checking how the CLI architecture, quickstart, and user-facing docs fit together." +summary: "mdBook documentation page for sophon-cli: Architecture. It contributes user and maintainer guidance that is built by the docs gate." +ontology_relations: + - relation: "part_of" + target: "docs/SUMMARY.md" + note: "Belongs to the mdBook documentation set." +--- + # Architecture ## Design principle diff --git a/docs/intro.md b/docs/intro.md index bf8cd6c..ae9b696 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -1,3 +1,15 @@ +--- +title: "sophon-cli" +when_to_read: + - "When reading or editing the mdBook documentation surface." + - "When checking how the CLI architecture, quickstart, and user-facing docs fit together." +summary: "mdBook documentation page for sophon-cli: sophon-cli. It contributes user and maintainer guidance that is built by the docs gate." +ontology_relations: + - relation: "part_of" + target: "docs/SUMMARY.md" + note: "Belongs to the mdBook documentation set." +--- + # sophon-cli A provider-agnostic Rust CLI that queries the Brave Search API and prints normalized text results. diff --git a/docs/quickstart.md b/docs/quickstart.md index 27d84c4..0bae643 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -1,3 +1,15 @@ +--- +title: "Quick Start" +when_to_read: + - "When reading or editing the mdBook documentation surface." + - "When checking how the CLI architecture, quickstart, and user-facing docs fit together." +summary: "mdBook documentation page for sophon-cli: Quick Start. It contributes user and maintainer guidance that is built by the docs gate." +ontology_relations: + - relation: "part_of" + target: "docs/SUMMARY.md" + note: "Belongs to the mdBook documentation set." +--- + # Quick Start ## Prerequisites diff --git a/justfile b/justfile index 8525a6f..42b833b 100644 --- a/justfile +++ b/justfile @@ -1,6 +1,7 @@ -# Check gate: formatter, linter (+ complexity), tests, docs build +# Check gate: formatter, linter (+ complexity), tests, docs metadata, docs build check: cargo fmt --check cargo clippy -- -D warnings -W clippy::complexity -W clippy::cognitive_complexity cargo test + python3 scripts/check_markdown_frontmatter.py mdbook build diff --git a/scripts/check_markdown_frontmatter.py b/scripts/check_markdown_frontmatter.py new file mode 100755 index 0000000..640f9a1 --- /dev/null +++ b/scripts/check_markdown_frontmatter.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Validate required Markdown frontmatter for tracked docs.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + + +REQUIRED_KEYS = ("title", "when_to_read", "summary", "ontology_relations") +RELATION_KEYS = ("relation", "target", "note") + + +def tracked_markdown_files() -> list[Path]: + result = subprocess.run( + ["git", "ls-files", "*.md"], + check=True, + capture_output=True, + text=True, + ) + skipped = {"AGENTS.md", "README.md", "docs/SUMMARY.md"} + return [Path(line) for line in result.stdout.splitlines() if line and line not in skipped] + + +def frontmatter_lines(path: Path) -> tuple[list[str] | None, str | None]: + text = path.read_text(encoding="utf-8") + if not text.startswith("---\n"): + return None, "missing YAML frontmatter at top of file" + + end = text.find("\n---\n", 4) + if end == -1: + return None, "missing closing YAML frontmatter delimiter" + + return text[4:end].splitlines(), None + + +def top_level_keys(lines: list[str]) -> set[str]: + keys: set[str] = set() + for line in lines: + if not line or line.startswith((" ", "\t", "-")): + continue + if ":" in line: + keys.add(line.split(":", 1)[0].strip()) + return keys + + +def ontology_relation_errors(lines: list[str]) -> list[str]: + errors: list[str] = [] + entries: list[set[str]] = [] + current: set[str] | None = None + in_relations = False + + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + + if not line.startswith((" ", "\t", "-")) and ":" in line: + in_relations = line.split(":", 1)[0].strip() == "ontology_relations" + current = None + continue + + if not in_relations: + continue + + if stripped.startswith("- "): + if current is not None: + entries.append(current) + current = set() + remainder = stripped[2:] + if ":" in remainder: + current.add(remainder.split(":", 1)[0].strip()) + continue + + if current is not None and ":" in stripped: + current.add(stripped.split(":", 1)[0].strip()) + + if current is not None: + entries.append(current) + + if not entries: + return ["ontology_relations must contain at least one relation entry"] + + for index, entry in enumerate(entries, start=1): + missing = [key for key in RELATION_KEYS if key not in entry] + if missing: + errors.append( + f"ontology_relations entry {index} missing key(s): {', '.join(missing)}" + ) + + return errors + + +def validate(path: Path) -> list[str]: + lines, frontmatter_error = frontmatter_lines(path) + if frontmatter_error is not None: + return [frontmatter_error] + + assert lines is not None + keys = top_level_keys(lines) + errors = [ + f"missing required frontmatter key: {key}" + for key in REQUIRED_KEYS + if key not in keys + ] + + if "ontology_relations" in keys: + errors.extend(ontology_relation_errors(lines)) + + return errors + + +def main() -> int: + failures: dict[Path, list[str]] = {} + for path in tracked_markdown_files(): + errors = validate(path) + if errors: + failures[path] = errors + + if failures: + print("Markdown frontmatter validation failed:", file=sys.stderr) + for path, errors in failures.items(): + print(f"- {path}", file=sys.stderr) + for error in errors: + print(f" - {error}", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/mdbook_strip_frontmatter.py b/scripts/mdbook_strip_frontmatter.py new file mode 100644 index 0000000..ab37ae4 --- /dev/null +++ b/scripts/mdbook_strip_frontmatter.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""mdBook preprocessor that strips YAML frontmatter from rendered chapters.""" + +from __future__ import annotations + +import json +import sys +from typing import Any + + +def strip_frontmatter(content: str) -> str: + """Remove a leading YAML frontmatter block from Markdown content.""" + if not content.startswith("---\n"): + return content + + end = content.find("\n---\n", 4) + if end == -1: + return content + + return content[end + len("\n---\n") :] + + +def strip_content_fields(value: Any) -> None: + """Recursively strip frontmatter from any mdBook chapter content field.""" + if isinstance(value, dict): + content = value.get("content") + if isinstance(content, str): + value["content"] = strip_frontmatter(content) + + for child in value.values(): + strip_content_fields(child) + return + + if isinstance(value, list): + for item in value: + strip_content_fields(item) + + +def main(argv: list[str]) -> int: + if len(argv) >= 2 and argv[1] == "supports": + return 0 + + _context, book = json.load(sys.stdin) + strip_content_fields(book) + json.dump(book, sys.stdout) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) From 5342ddcb2440974a60888c568d7024330d209848 Mon Sep 17 00:00:00 2001 From: larock22 Date: Sun, 26 Apr 2026 14:12:17 -0500 Subject: [PATCH 20/44] docs(changelog): standardize recent PR notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branch: main Changes Summary: CHANGELOG.md | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) Detailed Diffs (truncated to 200 lines): diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ac763b..69872ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,22 +21,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Bootstrap**: Provider registry (`src/bootstrap/provider_registry.rs`) for compile-time provider registration and metadata discovery. -- **Docs Guard**: Markdown frontmatter validator and Cargo-managed pre-push hook for the canonical `just check` gate. +- Provider registry composition layer for built-in provider registration, provider metadata discovery, and `SearchService` construction. (#7) +- Environment-filtered structured tracing spans for startup, search orchestration, provider adapters, and HTTP transport. Logs are written to stderr so CLI result output stays clean. (#8) +- Markdown frontmatter validation and mdBook frontmatter stripping in the canonical `just check` gate. (#9) +- Cargo-managed pre-push hook that runs `just check`. (#9) +- Repository operating surfaces: `.env.example`, CODEOWNERS, issue and PR templates, label definitions, AGENTS validation CI, and a `sophon-cli` agent skill. (#6) ### Changed -- **Main**: Refactored to use provider registry for provider instantiation instead of direct constructor calls. -- **Architecture Tests**: Updated to allow `bootstrap` module imports from `main.rs`. -- **HARNESS.md**: Updated harness map to reflect current validation chain. -- **Artifacts**: `.artifacts/` is now ignored and no longer tracked in Git. -- **Exa**: Default `/search` `contents` now requests **highlights** (with `maxCharacters` and the user query) and a **query-scoped summary** object instead of full-page **`text`**, so the API is not asked for article bodies for normal CLI usage. -- **Exa**: Normalized `snippet` is derived as **summary** (trimmed, capped) if non-empty, else **joined highlights** (separator ` … `, capped); **`text` is never used** as a snippet fallback, even when present in the response. +- `main.rs` now selects providers through `ProviderId` and `ProviderRegistry` instead of directly constructing Brave and Exa clients. (#7) +- Production startup registers only providers with valid environment configuration, and provider-unavailable errors list configured providers. (#7) +- Architecture tests now include the `bootstrap` composition layer boundary. (#7) +- `HARNESS.md` and architecture docs now reflect the bootstrap layer, docs metadata guard, cargo-husky hook, and current validation chain. (#7, #9) +- Exa `/search` requests use highlights plus a query-scoped summary instead of requesting full-page `text` for normal CLI output. (#5) +- Exa snippet normalization now prefers trimmed summaries, then capped joined highlights; `text` is not used as a snippet fallback. (#5) +- CLI news output now prints `snippet` when present, matching web-result rendering. (#5) ### Fixed -- **Exa**: Web results no longer dump full extracted page markdown into the terminal when `summary` is missing. +- Exa web results no longer dump full extracted page markdown into the terminal when `summary` is missing. (#5) -### Added +### Removed + +- Tracked `.artifacts/` planning and execution files; future local artifact output is ignored by Git. (#9) +- Legacy pre-commit configuration in favor of the Cargo-managed pre-push hook. (#9) + +### Security -- **CLI**: News rows print **`snippet`** when present, matching web results and providers that populate `NewsResult.snippet`. +- Structured logging avoids recording provider authentication headers or API keys. (#8) --- CHANGELOG.md | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ac763b..69872ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,22 +21,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Bootstrap**: Provider registry (`src/bootstrap/provider_registry.rs`) for compile-time provider registration and metadata discovery. -- **Docs Guard**: Markdown frontmatter validator and Cargo-managed pre-push hook for the canonical `just check` gate. +- Provider registry composition layer for built-in provider registration, provider metadata discovery, and `SearchService` construction. (#7) +- Environment-filtered structured tracing spans for startup, search orchestration, provider adapters, and HTTP transport. Logs are written to stderr so CLI result output stays clean. (#8) +- Markdown frontmatter validation and mdBook frontmatter stripping in the canonical `just check` gate. (#9) +- Cargo-managed pre-push hook that runs `just check`. (#9) +- Repository operating surfaces: `.env.example`, CODEOWNERS, issue and PR templates, label definitions, AGENTS validation CI, and a `sophon-cli` agent skill. (#6) ### Changed -- **Main**: Refactored to use provider registry for provider instantiation instead of direct constructor calls. -- **Architecture Tests**: Updated to allow `bootstrap` module imports from `main.rs`. -- **HARNESS.md**: Updated harness map to reflect current validation chain. -- **Artifacts**: `.artifacts/` is now ignored and no longer tracked in Git. -- **Exa**: Default `/search` `contents` now requests **highlights** (with `maxCharacters` and the user query) and a **query-scoped summary** object instead of full-page **`text`**, so the API is not asked for article bodies for normal CLI usage. -- **Exa**: Normalized `snippet` is derived as **summary** (trimmed, capped) if non-empty, else **joined highlights** (separator ` … `, capped); **`text` is never used** as a snippet fallback, even when present in the response. +- `main.rs` now selects providers through `ProviderId` and `ProviderRegistry` instead of directly constructing Brave and Exa clients. (#7) +- Production startup registers only providers with valid environment configuration, and provider-unavailable errors list configured providers. (#7) +- Architecture tests now include the `bootstrap` composition layer boundary. (#7) +- `HARNESS.md` and architecture docs now reflect the bootstrap layer, docs metadata guard, cargo-husky hook, and current validation chain. (#7, #9) +- Exa `/search` requests use highlights plus a query-scoped summary instead of requesting full-page `text` for normal CLI output. (#5) +- Exa snippet normalization now prefers trimmed summaries, then capped joined highlights; `text` is not used as a snippet fallback. (#5) +- CLI news output now prints `snippet` when present, matching web-result rendering. (#5) ### Fixed -- **Exa**: Web results no longer dump full extracted page markdown into the terminal when `summary` is missing. +- Exa web results no longer dump full extracted page markdown into the terminal when `summary` is missing. (#5) -### Added +### Removed + +- Tracked `.artifacts/` planning and execution files; future local artifact output is ignored by Git. (#9) +- Legacy pre-commit configuration in favor of the Cargo-managed pre-push hook. (#9) + +### Security -- **CLI**: News rows print **`snippet`** when present, matching web results and providers that populate `NewsResult.snippet`. +- Structured logging avoids recording provider authentication headers or API keys. (#8) From 4e2d18121ac18ab3dfa0ac36681513708ea203cd Mon Sep 17 00:00:00 2001 From: larock22 Date: Sun, 26 Apr 2026 14:55:45 -0500 Subject: [PATCH 21/44] feat: add hygiene checks to justfile and CI workflow - Introduced a new `hygiene` target in the justfile to run checks for unused dependencies, code duplication, technical debt, and large files. - Updated the CI workflow to install `cargo-udeps` and run the new `just hygiene` command alongside existing checks. - Enhanced documentation in HARNESS.md to include hygiene checks and their usage. --- .github/workflows/validate-agents.yml | 15 ++++++-- .jscpd.json | 10 ++++++ HARNESS.md | 11 ++++-- justfile | 17 +++++++++ scripts/check_large_files.sh | 50 +++++++++++++++++++++++++++ scripts/check_tech_debt.sh | 50 +++++++++++++++++++++++++++ scripts/large_files_allowlist.txt | 2 ++ scripts/tech_debt_allowlist.txt | 2 ++ 8 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 .jscpd.json create mode 100644 scripts/check_large_files.sh create mode 100644 scripts/check_tech_debt.sh create mode 100644 scripts/large_files_allowlist.txt create mode 100644 scripts/tech_debt_allowlist.txt diff --git a/.github/workflows/validate-agents.yml b/.github/workflows/validate-agents.yml index 97bd1b7..5b494ce 100644 --- a/.github/workflows/validate-agents.yml +++ b/.github/workflows/validate-agents.yml @@ -61,10 +61,21 @@ jobs: - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@stable - - name: Install just and mdbook + - name: Install just, mdbook, and cargo-udeps uses: taiki-e/install-action@v2 with: - tool: just,mdbook + tool: just,mdbook,cargo-udeps + + - name: Install nightly toolchain for cargo-udeps + uses: dtolnay/rust-toolchain@nightly - name: Verify canonical command works run: just check + + - name: Setup Node.js for jscpd + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Run hygiene checks + run: just hygiene diff --git a/.jscpd.json b/.jscpd.json new file mode 100644 index 0000000..a2ae8f4 --- /dev/null +++ b/.jscpd.json @@ -0,0 +1,10 @@ +{ + "threshold": 3, + "minLines": 5, + "minTokens": 50, + "reporters": ["consoleFull"], + "absolute": false, + "path": ["src", "tests"], + "pattern": "**/*.rs", + "ignore": ["**/target/**"] +} diff --git a/HARNESS.md b/HARNESS.md index 0f3d23b..33ed95a 100644 --- a/HARNESS.md +++ b/HARNESS.md @@ -84,7 +84,7 @@ No link checker or nav check is configured. ### Layer 6: CI Matrix | Workflow | Trigger | Checks | |----------|---------|--------| -| `.github/workflows/validate-agents.yml` | pull requests and pushes to `main` | Verifies key `AGENTS.md` referenced paths exist, installs `just`/`mdbook`, and runs `just check` | +| `.github/workflows/validate-agents.yml` | pull requests and pushes to `main` | Verifies key `AGENTS.md` referenced paths exist, installs `just`/`mdbook`/`cargo-udeps`, and runs `just check` + `just hygiene` | ### Layer 7: Evidence Workflow | Artifact | Location | Tracking | Notes | @@ -111,12 +111,19 @@ Ordered list of checks as executed by the canonical entry point: 4. `python3 scripts/check_markdown_frontmatter.py` 5. `mdbook build` +Ordered list of hygiene checks as executed by the hygiene entry point: +1. `cargo +nightly udeps` +2. `npx --yes jscpd@4.0.5` +3. `bash scripts/check_tech_debt.sh` +4. `bash scripts/check_large_files.sh` + ## Quick Reference - **Run all local checks:** `just check` +- **Run all hygiene checks:** `just hygiene` - **Run tests only:** `cargo test` - **Run lint only:** `cargo clippy -- -D warnings -W clippy::complexity -W clippy::cognitive_complexity` - **Run formatter check only:** `cargo fmt --check` -- **Run CI locally:** `just check` covers the workflow's canonical command; path existence checks are in `.github/workflows/validate-agents.yml` +- **Run CI locally:** `just check && just hygiene` covers workflow command checks; path existence checks are in `.github/workflows/validate-agents.yml` - **Add a new check:** Edit `justfile` and append to the `check` recipe ## Source Index diff --git a/justfile b/justfile index 42b833b..bf087f9 100644 --- a/justfile +++ b/justfile @@ -5,3 +5,20 @@ check: cargo test python3 scripts/check_markdown_frontmatter.py mdbook build + +# Hygiene gate: dependencies, duplication, tech debt markers, large files +hygiene: udeps duplicates tech-debt large-files + +# Uses nightly because cargo-udeps relies on nightly internals. +udeps: + if ! cargo udeps --version >/dev/null 2>&1; then cargo install cargo-udeps --locked; fi + cargo +nightly udeps + +duplicates: + npx --yes jscpd@4.0.5 + +tech-debt: + bash scripts/check_tech_debt.sh + +large-files: + bash scripts/check_large_files.sh diff --git a/scripts/check_large_files.sh b/scripts/check_large_files.sh new file mode 100644 index 0000000..a10d2dc --- /dev/null +++ b/scripts/check_large_files.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +max_bytes=512000 +max_kb=500 +allowlist_file="scripts/large_files_allowlist.txt" + +is_allowlisted() { + local candidate="$1" + if [ ! -f "${allowlist_file}" ]; then + return 1 + fi + + while IFS= read -r pattern; do + [ -z "${pattern}" ] && continue + [[ "${pattern}" =~ ^# ]] && continue + if [ "${candidate}" = "${pattern}" ]; then + return 0 + fi + done <"${allowlist_file}" + + return 1 +} + +offenders=() +while IFS= read -r -d '' file; do + if [ ! -f "${file}" ]; then + continue + fi + if is_allowlisted "${file}"; then + continue + fi + + size_bytes="$(wc -c <"${file}" | tr -d '[:space:]')" + if [ "${size_bytes}" -gt "${max_bytes}" ]; then + offenders+=("${file}:${size_bytes}") + fi +done < <(git ls-files -z) + +if [ "${#offenders[@]}" -gt 0 ]; then + echo "error: tracked files exceed ${max_kb} KB (${max_bytes} bytes):" + for offender in "${offenders[@]}"; do + file="${offender%%:*}" + size="${offender##*:}" + printf ' - %s (%s bytes)\n' "${file}" "${size}" + done + exit 1 +fi + +echo "Large file check passed." diff --git a/scripts/check_tech_debt.sh b/scripts/check_tech_debt.sh new file mode 100644 index 0000000..35ba0ce --- /dev/null +++ b/scripts/check_tech_debt.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v rg >/dev/null 2>&1; then + echo "error: ripgrep (rg) is required for tech debt checks." + exit 1 +fi + +allowlist_file="scripts/tech_debt_allowlist.txt" +matches_file="$(mktemp)" +cleanup() { + rm -f "${matches_file}" +} +trap cleanup EXIT + +include_globs=( + "*.rs" + "*.toml" +) + +exclude_globs=( + "target/**" + ".git/**" + "node_modules/**" +) + +rg_args=(--line-number --with-filename --no-heading "(TODO|FIXME)") +for glob in "${include_globs[@]}"; do + rg_args+=(--glob "${glob}") +done +for glob in "${exclude_globs[@]}"; do + rg_args+=(--glob "!${glob}") +done + +if rg "${rg_args[@]}" . >"${matches_file}"; then + if [ -f "${allowlist_file}" ]; then + filtered_file="$(mktemp)" + grep -F -v -f "${allowlist_file}" "${matches_file}" >"${filtered_file}" || true + mv "${filtered_file}" "${matches_file}" + fi + + if [ -s "${matches_file}" ]; then + echo "error: found TODO/FIXME markers." + echo "Add exact match substrings to ${allowlist_file} to allow specific lines." + cat "${matches_file}" + exit 1 + fi +fi + +echo "Tech debt marker check passed." diff --git a/scripts/large_files_allowlist.txt b/scripts/large_files_allowlist.txt new file mode 100644 index 0000000..934b989 --- /dev/null +++ b/scripts/large_files_allowlist.txt @@ -0,0 +1,2 @@ +# Relative paths allowed to exceed the large-file threshold. +docs/sophon.png diff --git a/scripts/tech_debt_allowlist.txt b/scripts/tech_debt_allowlist.txt new file mode 100644 index 0000000..6e74a45 --- /dev/null +++ b/scripts/tech_debt_allowlist.txt @@ -0,0 +1,2 @@ +# Add line substrings that should be ignored by check_tech_debt.sh. +# Keep this list minimal and review entries regularly. From d7b74956862e78df374c6069923f3f9151376608 Mon Sep 17 00:00:00 2001 From: larock22 Date: Sun, 26 Apr 2026 15:30:00 -0500 Subject: [PATCH 22/44] T001: add fan-out domain response types Add provider-agnostic batch response and failure wrappers with a unit proof for holding successes and provider errors. Refs: plan/2026-04-26_15-04-07_all-enabled-provider-fanout/PLAN.md --- src/domain/result.rs | 49 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/domain/result.rs b/src/domain/result.rs index 572df8d..550af8f 100644 --- a/src/domain/result.rs +++ b/src/domain/result.rs @@ -1,3 +1,5 @@ +use crate::domain::error::SearchError; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct PageToken(pub String); @@ -10,6 +12,19 @@ pub struct SearchResponse { pub next_page: Option, } +#[derive(Debug)] +pub struct SearchBatchResponse { + pub query: String, + pub responses: Vec, + pub failures: Vec, +} + +#[derive(Debug)] +pub struct ProviderSearchFailure { + pub provider: String, + pub error: SearchError, +} + #[derive(Debug, Clone, PartialEq)] pub enum SearchResult { Web(WebResult), @@ -51,3 +66,37 @@ pub struct VideoResult { pub duration: Option, pub published_at: Option, } + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::error::SearchError; + + #[test] + fn search_batch_response_can_hold_success_and_failure() { + let response = SearchResponse { + query: "rust".to_string(), + provider: "brave".to_string(), + results: vec![], + total_estimated: None, + next_page: None, + }; + let failure = ProviderSearchFailure { + provider: "exa".to_string(), + error: SearchError::InvalidQuery("unsupported".to_string()), + }; + let batch = SearchBatchResponse { + query: "rust".to_string(), + responses: vec![response], + failures: vec![failure], + }; + + assert_eq!(batch.query, "rust"); + assert_eq!(batch.responses[0].provider, "brave"); + assert_eq!(batch.failures[0].provider, "exa"); + assert_eq!( + batch.failures[0].error.to_string(), + "invalid query: unsupported" + ); + } +} From 24eea8725c420204549120e3a763319aa5ae9334 Mon Sep 17 00:00:00 2001 From: larock22 Date: Sun, 26 Apr 2026 15:30:35 -0500 Subject: [PATCH 23/44] T002: add app fan-out search service Add sequential fan-out orchestration over SearchProvider trait objects with tests proving provider order and failure capture. Refs: plan/2026-04-26_15-04-07_all-enabled-provider-fanout/PLAN.md --- src/app/fanout_search_service.rs | 135 +++++++++++++++++++++++++++++++ src/app/mod.rs | 1 + 2 files changed, 136 insertions(+) create mode 100644 src/app/fanout_search_service.rs diff --git a/src/app/fanout_search_service.rs b/src/app/fanout_search_service.rs new file mode 100644 index 0000000..16ae62e --- /dev/null +++ b/src/app/fanout_search_service.rs @@ -0,0 +1,135 @@ +use crate::domain::provider::SearchProvider; +use crate::domain::query::SearchQuery; +use crate::domain::result::{ProviderSearchFailure, SearchBatchResponse, SearchResponse}; + +pub struct FanoutSearchService { + providers: Vec>, +} + +impl FanoutSearchService { + pub fn new(providers: Vec>) -> Self { + Self { providers } + } + + pub async fn search_all(&self, query: SearchQuery) -> SearchBatchResponse { + let mut responses: Vec = Vec::new(); + let mut failures: Vec = Vec::new(); + + for provider in &self.providers { + let provider_id = provider.id(); + match provider.search(&query).await { + Ok(response) => responses.push(response), + Err(error) => failures.push(ProviderSearchFailure { + provider: provider_id, + error, + }), + } + } + + SearchBatchResponse { + query: query.text, + responses, + failures, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::error::SearchError; + use crate::domain::provider::{ProviderCapabilities, SearchProvider}; + use crate::domain::query::SearchQuery; + use crate::domain::result::SearchResponse; + use crate::domain::types::SearchType; + use async_trait::async_trait; + + enum MockOutcome { + Success(SearchResponse), + Failure(&'static str), + } + + struct MockProvider { + id: &'static str, + outcome: MockOutcome, + } + + #[async_trait] + impl SearchProvider for MockProvider { + fn id(&self) -> String { + self.id.to_string() + } + + fn capabilities(&self) -> ProviderCapabilities { + ProviderCapabilities { + web: true, + news: false, + images: false, + videos: false, + pagination: false, + safe_search: false, + time_range_filter: false, + } + } + + async fn search(&self, _query: &SearchQuery) -> Result { + match &self.outcome { + MockOutcome::Success(response) => Ok(response.clone()), + MockOutcome::Failure(message) => Err(SearchError::Provider(message.to_string())), + } + } + } + + fn response(provider: &str) -> SearchResponse { + SearchResponse { + query: "rust".to_string(), + provider: provider.to_string(), + results: vec![], + total_estimated: None, + next_page: None, + } + } + + fn query() -> SearchQuery { + SearchQuery { + text: "rust".to_string(), + search_type: SearchType::Web, + limit: None, + offset: None, + safe_search: None, + country: None, + language: None, + time_range: None, + } + } + + #[tokio::test] + async fn search_all_preserves_provider_order_and_records_failures() { + let service = FanoutSearchService::new(vec![ + Box::new(MockProvider { + id: "brave", + outcome: MockOutcome::Success(response("brave")), + }), + Box::new(MockProvider { + id: "broken", + outcome: MockOutcome::Failure("temporary outage"), + }), + Box::new(MockProvider { + id: "exa", + outcome: MockOutcome::Success(response("exa")), + }), + ]); + + let batch = service.search_all(query()).await; + + assert_eq!(batch.query, "rust"); + assert_eq!(batch.responses[0].provider, "brave"); + assert_eq!(batch.responses[1].provider, "exa"); + assert_eq!(batch.failures.len(), 1); + assert_eq!(batch.failures[0].provider, "broken"); + assert_eq!( + batch.failures[0].error.to_string(), + "provider error: temporary outage" + ); + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index e0a164b..577c446 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1 +1,2 @@ +pub mod fanout_search_service; pub mod search_service; From f2f647d00080affaa71b7d58d26dad71dc812679 Mon Sep 17 00:00:00 2001 From: larock22 Date: Sun, 26 Apr 2026 15:31:14 -0500 Subject: [PATCH 24/44] T003: build fan-out service from enabled providers Add build_all_enabled with empty-registry errors and stable provider-order proof for fan-out registry construction. Refs: plan/2026-04-26_15-04-07_all-enabled-provider-fanout/PLAN.md --- src/bootstrap/provider_registry.rs | 104 +++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/src/bootstrap/provider_registry.rs b/src/bootstrap/provider_registry.rs index d11cc41..101d99a 100644 --- a/src/bootstrap/provider_registry.rs +++ b/src/bootstrap/provider_registry.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::fmt; +use crate::app::fanout_search_service::FanoutSearchService; use crate::app::search_service::SearchService; use crate::domain::provider::SearchProvider; use crate::providers::brave::client::BraveProvider; @@ -37,6 +38,8 @@ pub enum BuildSearchServiceError { provider: ProviderId, available: Vec, }, + #[error("no configured providers; set BRAVE_API_KEY and/or EXA_API_KEY")] + NoProvidersAvailable, } impl ProviderRegistry { @@ -97,6 +100,26 @@ impl ProviderRegistry { Ok(SearchService::new(builder())) } + + pub fn build_all_enabled(&self) -> Result { + let provider_ids = self.available_providers(); + if provider_ids.is_empty() { + return Err(BuildSearchServiceError::NoProvidersAvailable); + } + + let providers = provider_ids + .into_iter() + .map(|provider_id| { + let builder = self + .builders + .get(&provider_id) + .expect("available provider has a registered builder"); + builder() + }) + .collect(); + + Ok(FanoutSearchService::new(providers)) + } } #[cfg(test)] @@ -106,6 +129,7 @@ mod tests { use crate::domain::provider::ProviderCapabilities; use crate::domain::query::SearchQuery; use crate::domain::result::SearchResponse; + use crate::domain::types::SearchType; use async_trait::async_trait; use std::ffi::OsString; use std::sync::{Mutex, OnceLock}; @@ -141,6 +165,52 @@ mod tests { } } + struct NamedMockProvider { + id: &'static str, + } + + #[async_trait] + impl SearchProvider for NamedMockProvider { + fn id(&self) -> String { + self.id.to_string() + } + + fn capabilities(&self) -> ProviderCapabilities { + ProviderCapabilities { + web: true, + news: true, + images: false, + videos: false, + pagination: false, + safe_search: false, + time_range_filter: false, + } + } + + async fn search(&self, query: &SearchQuery) -> Result { + Ok(SearchResponse { + query: query.text.clone(), + provider: self.id.to_string(), + results: vec![], + total_estimated: None, + next_page: None, + }) + } + } + + fn search_query() -> SearchQuery { + SearchQuery { + text: "rust".to_string(), + search_type: SearchType::Web, + limit: None, + offset: None, + safe_search: None, + country: None, + language: None, + time_range: None, + } + } + fn env_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) @@ -170,6 +240,7 @@ mod tests { assert!(available.is_empty()); } Ok(_) => panic!("expected ProviderUnavailable error"), + Err(other) => panic!("expected ProviderUnavailable error, got {other}"), } } @@ -212,4 +283,37 @@ mod tests { vec![ProviderId::Brave, ProviderId::Exa] ); } + + #[tokio::test] + async fn build_all_enabled_uses_stable_order_and_rejects_empty_registry() { + let empty = ProviderRegistry::empty(); + match empty.build_all_enabled() { + Err(BuildSearchServiceError::NoProvidersAvailable) => {} + Ok(_) => panic!("expected NoProvidersAvailable error"), + Err(other) => panic!("expected NoProvidersAvailable error, got {other}"), + } + + let mut registry = ProviderRegistry::empty(); + registry.register( + ProviderId::Exa, + Box::new(|| Box::new(NamedMockProvider { id: "exa" })), + ); + registry.register( + ProviderId::Brave, + Box::new(|| Box::new(NamedMockProvider { id: "brave" })), + ); + + let service = registry + .build_all_enabled() + .expect("fan-out service builds"); + let batch = service.search_all(search_query()).await; + + let providers: Vec<&str> = batch + .responses + .iter() + .map(|response| response.provider.as_str()) + .collect(); + assert_eq!(providers, vec!["brave", "exa"]); + assert!(batch.failures.is_empty()); + } } From 3d0e0b94ac46ba0193733c7444eb9c366d0ba3e1 Mon Sep 17 00:00:00 2001 From: larock22 Date: Sun, 26 Apr 2026 15:31:58 -0500 Subject: [PATCH 25/44] T004: parse all as a CLI provider Add all to CLI provider parsing and prove it preserves the brave default; add temporary non-concrete main handling until fan-out wiring replaces it. Refs: plan/2026-04-26_15-04-07_all-enabled-provider-fanout/PLAN.md --- src/cli/args.rs | 12 ++++++++++++ src/main.rs | 1 + 2 files changed, 13 insertions(+) diff --git a/src/cli/args.rs b/src/cli/args.rs index bc4601e..0e1b8ce 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -41,6 +41,7 @@ impl From for crate::domain::types::SafeSearch { pub enum CliProvider { Brave, Exa, + All, } #[derive(Parser, Debug)] @@ -89,4 +90,15 @@ mod tests { let default_args = CliArgs::parse_from(["sophon-cli", "rust"]); assert_eq!(default_args.provider, CliProvider::Brave); } + + #[test] + fn test_cli_provider_parses_all_and_defaults_to_brave() { + let all_args = CliArgs::try_parse_from(["sophon-cli", "rust", "--provider", "all"]) + .expect("all provider parses"); + assert_eq!(all_args.provider, CliProvider::All); + + let default_args = + CliArgs::try_parse_from(["sophon-cli", "rust"]).expect("default provider parses"); + assert_eq!(default_args.provider, CliProvider::Brave); + } } diff --git a/src/main.rs b/src/main.rs index 17dabeb..ab884d9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ impl From for ProviderId { match provider { CliProvider::Brave => ProviderId::Brave, CliProvider::Exa => ProviderId::Exa, + CliProvider::All => panic!("all-provider mode is handled by fan-out wiring"), } } } From 00718843bc74ca053634d05ed4768e26fd8c6865 Mon Sep 17 00:00:00 2001 From: larock22 Date: Sun, 26 Apr 2026 15:32:41 -0500 Subject: [PATCH 26/44] T005: render fan-out CLI output Add CLI-only fan-out text rendering with summary, per-provider success sections, and failure lines. Refs: plan/2026-04-26_15-04-07_all-enabled-provider-fanout/PLAN.md --- src/cli/output.rs | 59 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/src/cli/output.rs b/src/cli/output.rs index 652ba19..a3a695d 100644 --- a/src/cli/output.rs +++ b/src/cli/output.rs @@ -1,4 +1,4 @@ -use crate::domain::result::{SearchResponse, SearchResult}; +use crate::domain::result::{SearchBatchResponse, SearchResponse, SearchResult}; pub fn render_text(response: &SearchResponse) -> String { let mut lines = vec![ @@ -46,9 +46,34 @@ pub fn render_text(response: &SearchResponse) -> String { lines.join("\n") } +pub fn render_fanout_text(response: &SearchBatchResponse) -> String { + let mut lines = vec![ + format!("Query: {}", response.query), + format!("Providers succeeded: {}", response.responses.len()), + format!("Providers failed: {}", response.failures.len()), + String::new(), + ]; + + for provider_response in &response.responses { + lines.push(format!("== {} ==", provider_response.provider)); + lines.push(render_text(provider_response)); + lines.push(String::new()); + } + + if !response.failures.is_empty() { + lines.push("== Failures ==".to_string()); + for failure in &response.failures { + lines.push(format!("- {}: {}", failure.provider, failure.error)); + } + } + + lines.join("\n") +} + #[cfg(test)] mod tests { use super::*; + use crate::domain::error::SearchError; use crate::domain::result::*; #[test] @@ -97,4 +122,36 @@ mod tests { assert!(text.contains("[IMAGE] Rust Logo")); assert!(text.contains("[VIDEO] Rust Tutorial")); } + + #[test] + fn test_render_fanout_text_includes_successes_and_failures() { + let response = SearchBatchResponse { + query: "rust".to_string(), + responses: vec![SearchResponse { + query: "rust".to_string(), + provider: "brave".to_string(), + results: vec![SearchResult::Web(WebResult { + title: "Rust Lang".to_string(), + url: "https://rust-lang.org".to_string(), + snippet: Some("Safe systems".to_string()), + display_url: None, + })], + total_estimated: None, + next_page: None, + }], + failures: vec![ProviderSearchFailure { + provider: "exa".to_string(), + error: SearchError::InvalidQuery("unsupported".to_string()), + }], + }; + + let text = render_fanout_text(&response); + + assert!(text.contains("Query: rust")); + assert!(text.contains("Providers succeeded: 1")); + assert!(text.contains("Providers failed: 1")); + assert!(text.contains("== brave ==")); + assert!(text.contains("Rust Lang")); + assert!(text.contains("- exa: invalid query: unsupported")); + } } From 8be90bc29a7f5e78307605a2fe88cb72b0be5d85 Mon Sep 17 00:00:00 2001 From: larock22 Date: Sun, 26 Apr 2026 15:33:17 -0500 Subject: [PATCH 27/44] T006: wire all-provider fan-out in main Branch main between single-provider search and all-enabled fan-out, with nonzero exit when no providers succeed or none are configured. Refs: plan/2026-04-26_15-04-07_all-enabled-provider-fanout/PLAN.md --- src/main.rs | 68 ++++++++++++++++++++++++++-------------- tests/fanout_cli_test.rs | 19 +++++++++++ 2 files changed, 63 insertions(+), 24 deletions(-) create mode 100644 tests/fanout_cli_test.rs diff --git a/src/main.rs b/src/main.rs index ab884d9..370880b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,16 +8,32 @@ mod transport; use bootstrap::provider_registry::{ProviderId, ProviderRegistry}; use clap::Parser; use cli::args::{CliArgs, CliProvider}; -use cli::output::render_text; +use cli::output::{render_fanout_text, render_text}; use domain::query::SearchQuery; use tracing_subscriber::EnvFilter; -impl From for ProviderId { - fn from(provider: CliProvider) -> Self { - match provider { - CliProvider::Brave => ProviderId::Brave, - CliProvider::Exa => ProviderId::Exa, - CliProvider::All => panic!("all-provider mode is handled by fan-out wiring"), +async fn run_single_provider( + registry: &ProviderRegistry, + provider_id: ProviderId, + query: SearchQuery, +) { + tracing::info!(provider = %provider_id, query = %query.text, "initializing search service"); + + let service = registry.build(provider_id).unwrap_or_else(|error| { + tracing::error!(%error, "failed to build provider"); + eprintln!("{error}"); + std::process::exit(1); + }); + + match service.search(query).await { + Ok(response) => { + tracing::info!(result_count = response.results.len(), total_estimated = ?response.total_estimated, "search completed"); + println!("{}", render_text(&response)); + } + Err(e) => { + tracing::error!(error = %e, "search failed"); + eprintln!("Search failed: {}", e); + std::process::exit(1); } } } @@ -64,25 +80,29 @@ async fn main() { time_range: None, }; - let provider_id = ProviderId::from(args.provider); - tracing::info!(provider = %provider_id, query = %query.text, "initializing search service"); - let registry = ProviderRegistry::production_from_env(); - let service = registry.build(provider_id).unwrap_or_else(|error| { - tracing::error!(%error, "failed to build provider"); - eprintln!("{error}"); - std::process::exit(1); - }); - match service.search(query).await { - Ok(response) => { - tracing::info!(result_count = response.results.len(), total_estimated = ?response.total_estimated, "search completed"); - println!("{}", render_text(&response)); - } - Err(e) => { - tracing::error!(error = %e, "search failed"); - eprintln!("Search failed: {}", e); - std::process::exit(1); + match args.provider { + CliProvider::Brave => run_single_provider(®istry, ProviderId::Brave, query).await, + CliProvider::Exa => run_single_provider(®istry, ProviderId::Exa, query).await, + CliProvider::All => { + tracing::info!(query = %query.text, "initializing all-enabled provider fan-out service"); + let service = registry.build_all_enabled().unwrap_or_else(|error| { + tracing::error!(%error, "failed to build fan-out providers"); + eprintln!("{error}"); + std::process::exit(1); + }); + + let response = service.search_all(query).await; + tracing::info!( + successful_providers = response.responses.len(), + failed_providers = response.failures.len(), + "fan-out search completed" + ); + println!("{}", render_fanout_text(&response)); + if response.responses.is_empty() { + std::process::exit(1); + } } } } diff --git a/tests/fanout_cli_test.rs b/tests/fanout_cli_test.rs new file mode 100644 index 0000000..201edfb --- /dev/null +++ b/tests/fanout_cli_test.rs @@ -0,0 +1,19 @@ +use std::process::Command; + +#[test] +fn provider_all_without_config_exits_nonzero_with_no_provider_error() { + let output = Command::new(env!("CARGO_BIN_EXE_sophon-cli")) + .args(["rust", "--provider", "all"]) + .env_remove("BRAVE_API_KEY") + .env_remove("EXA_API_KEY") + .current_dir(std::env::temp_dir()) + .output() + .expect("sophon-cli runs"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("no configured providers"), + "stderr did not contain no configured providers: {stderr}" + ); +} From ec1a92262bb87cbf664c71ded7b96a758f43ec4a Mon Sep 17 00:00:00 2001 From: larock22 Date: Sun, 26 Apr 2026 15:34:15 -0500 Subject: [PATCH 28/44] T007: document all-provider fan-out Update README and mdBook docs for --provider all, environment-enabled providers, no-provider behavior, and fan-out architecture. Refs: plan/2026-04-26_15-04-07_all-enabled-provider-fanout/PLAN.md --- README.md | 11 ++++++++-- docs/architecture.md | 48 ++++++++++++++++++++++++++++++++------------ docs/intro.md | 9 +++++---- docs/quickstart.md | 14 ++++++++++++- 4 files changed, 62 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 660867f..202f315 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ![sophon](docs/sophon.png) -A provider-agnostic Rust CLI that queries Brave Search or Exa and prints normalized text results. +A provider-agnostic Rust CLI that queries Brave Search, Exa, or every environment-enabled provider and prints normalized text results. ## Install @@ -22,6 +22,9 @@ cargo run -- "rust programming" cargo run -- "rust programming" --provider brave cargo run -- "rust programming" --provider exa +# Query every provider enabled by environment variables +cargo run -- "rust async trait" --provider all + # Show package info cargo run -- --about @@ -41,7 +44,7 @@ echo "BRAVE_API_KEY=your_key_here" > .env echo "EXA_API_KEY=your_key_here" > .env ``` -You can also export the variables directly in your shell instead of using `.env`. +You can also export the variables directly in your shell instead of using `.env`. `--provider all` queries every provider enabled by the current environment; for example, set both `BRAVE_API_KEY` and `EXA_API_KEY` to fan out to both providers. ## Example usage @@ -54,12 +57,16 @@ sophon-cli "open source ai" --provider brave --search-type news --limit 3 # Exa search sophon-cli "vector database benchmarks" --provider exa --limit 5 + +# All configured providers, with per-provider successes and failures +sophon-cli "rust async trait" --provider all ``` ## Supported providers - `brave` for web, news, images, and video search - `exa` for Exa search results mapped into the shared domain model +- `all` to query every configured provider in stable order and print per-provider failures when one provider rejects or fails a request ## Docs diff --git a/docs/architecture.md b/docs/architecture.md index fccb455..6b393b4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,13 +38,14 @@ bootstrap composes app + providers + transport at startup 2. src/main.rs └── CliArgs::parse() produces CliArgs { query, provider, search_type, limit, ... } - └── converts CliProvider to ProviderId - └── asks the bootstrap ProviderRegistry to build SearchService └── maps CliArgs → SearchQuery + └── for `brave` or `exa`, asks ProviderRegistry::build(provider) for SearchService + └── for `all`, asks ProviderRegistry::build_all_enabled() for FanoutSearchService -3. src/app/search_service.rs - └── SearchService::search(SearchQuery) awaits - └── delegates to dyn SearchProvider +3. src/app/search_service.rs or src/app/fanout_search_service.rs + └── SearchService::search(SearchQuery) awaits one provider + └── FanoutSearchService::search_all(SearchQuery) awaits enabled providers sequentially + └── delegates to dyn SearchProvider trait objects 4. src/providers/*/client.rs └── provider-specific SearchProvider::search(&SearchQuery) @@ -62,7 +63,8 @@ bootstrap composes app + providers + transport at startup └── transforms DTOs into domain SearchResult::News items 7. src/cli/output.rs - └── render_text(&SearchResponse) → String + └── render_text(&SearchResponse) → String for single-provider output + └── render_fanout_text(&SearchBatchResponse) → String for all-provider output 8. src/main.rs └── println!("{}", rendered_string) @@ -75,11 +77,13 @@ Every public function that crosses a module boundary uses a domain type. | Boundary | Function | Input type | Output type | |----------|----------|------------|-------------| | CLI → App | `SearchService::search` | `SearchQuery` | `Result` | +| CLI → App | `FanoutSearchService::search_all` | `SearchQuery` | `SearchBatchResponse` | | App → Provider | `SearchProvider::search` | `&SearchQuery` | `Result` | | Provider → Transport | `HttpClient::{get_json, post_json}` | `url, headers, params/body` | `Result` | | Transport → Provider | (JSON response body) | bytes | provider DTOs such as `Brave*Response` or `ExaSearchResponse` | | Provider → Domain | `map_*_response` | provider DTOs | `SearchResponse` | -| App → CLI | `render_text` | `&SearchResponse` | `String` | +| CLI rendering | `render_text` | `&SearchResponse` | `String` | +| CLI rendering | `render_fanout_text` | `&SearchBatchResponse` | `String` | ## Domain type reference @@ -105,7 +109,7 @@ pub struct SearchResponse { pub query: String, pub provider: String, pub results: Vec, - pub total_estimated: Option, + pub total_estimated: Option, pub next_page: Option, } ``` @@ -119,6 +123,21 @@ pub enum SearchResult { } ``` +Fan-out results stay provider-agnostic in the domain layer: + +```rust +pub struct SearchBatchResponse { + pub query: String, + pub responses: Vec, + pub failures: Vec, +} + +pub struct ProviderSearchFailure { + pub provider: String, + pub error: SearchError, +} +``` + ### Provider trait ```rust @@ -148,8 +167,8 @@ Errors are created at the layer where the failure occurs and bubble upward uncha 1. **Transport layer** — `reqwest` failures, non-2xx HTTP status, or JSON decode errors become `SearchError::Transport`, `SearchError::Provider`, or `SearchError::Decode`. 2. **Provider layer** — can surface `SearchError` directly; does not wrap in another error type. -3. **App layer** — `SearchService` returns the `SearchError` untouched. -4. **CLI layer** — `main.rs` matches on `SearchError` and prints a human-readable message to `stderr`, then exits with code `1`. +3. **App layer** — `SearchService` returns the `SearchError` untouched; `FanoutSearchService` records per-provider failures in `SearchBatchResponse` and continues to later providers. +4. **CLI layer** — `main.rs` matches on single-provider `SearchError` and prints a human-readable message to `stderr`, or renders fan-out successes and failures and exits with code `1` when no provider succeeded. This keeps error handling simple: there is only one error type in the public API. @@ -177,12 +196,15 @@ Unsupported Exa inputs are rejected at runtime instead of being ignored: `Images ## Runtime provider selection -`main.rs` remains the binary edge that chooses the requested provider ID. Concrete provider construction lives in `src/bootstrap/provider_registry.rs`, where typed provider config, HTTP transport, provider clients, and `SearchService` are composed. +`main.rs` remains the binary edge that chooses either a single-provider path or the all-enabled-provider fan-out path. Concrete provider construction lives in `src/bootstrap/provider_registry.rs`, where typed provider config, HTTP transport, provider clients, `SearchService`, and `FanoutSearchService` are composed. -- `--provider brave` maps to `ProviderId::Brave`; the registry includes it only when `BRAVE_API_KEY` is configured -- `--provider exa` maps to `ProviderId::Exa`; the registry includes it only when `EXA_API_KEY` is configured +- `--provider brave` uses `ProviderRegistry::build(ProviderId::Brave)`; the registry includes it only when `BRAVE_API_KEY` is configured +- `--provider exa` uses `ProviderRegistry::build(ProviderId::Exa)`; the registry includes it only when `EXA_API_KEY` is configured +- `--provider all` uses `ProviderRegistry::build_all_enabled()` to build a `FanoutSearchService` from every configured provider in stable order - omitting `--provider` still selects Brave +`FanoutSearchService` is application-layer orchestration over multiple domain `SearchProvider` trait objects. It does not render output; fan-out rendering remains in the CLI layer through `render_fanout_text`. + ## Architecture enforcement The rules are verified by `tests/architecture_test.rs`. These tests scan source files and fail if a forbidden import pattern is found. diff --git a/docs/intro.md b/docs/intro.md index ae9b696..9f55c36 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -12,21 +12,22 @@ ontology_relations: # sophon-cli -A provider-agnostic Rust CLI that queries the Brave Search API and prints normalized text results. +A provider-agnostic Rust CLI that queries Brave Search, Exa, or every environment-enabled provider and prints normalized text results. ## What it does - Parses CLI arguments - Builds a provider-agnostic `SearchQuery` -- Delegates to a `SearchProvider` (currently Brave) -- Renders results as human-readable text +- Delegates to a single `SearchProvider` for `--provider brave` or `--provider exa` +- Fans out sequentially to all environment-enabled providers for `--provider all` +- Renders single-provider or per-provider fan-out results as human-readable text ## Project structure - `src/domain/` — pure types and traits; no HTTP, no CLI - `src/transport/` — `HttpClient` trait + `reqwest` adapter - `src/providers/brave/` — Brave-specific DTOs, mapper, and client -- `src/app/` — `SearchService` orchestrator +- `src/app/` — `SearchService` and `FanoutSearchService` orchestrators - `src/cli/` — argument parsing and output rendering - `tests/architecture_test.rs` — boundary tests enforcing layer isolation diff --git a/docs/quickstart.md b/docs/quickstart.md index 0bae643..3f62d60 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -15,7 +15,7 @@ ontology_relations: ## Prerequisites - Rust toolchain (edition 2024) -- `BRAVE_API_KEY` in a `.env` file at the project root +- `BRAVE_API_KEY` and/or `EXA_API_KEY` in a `.env` file at the project root ## Install the task runner @@ -35,6 +35,18 @@ just check cargo run -- "rust programming" ``` +## Run all configured providers + +Set both provider keys when you want `--provider all` to query Brave and Exa in one run: + +```bash +export BRAVE_API_KEY=your_brave_key +export EXA_API_KEY=your_exa_key +cargo run -- "rust async trait" --provider all +``` + +`--provider all` includes only providers enabled by the current environment variables. If neither `BRAVE_API_KEY` nor `EXA_API_KEY` is available, the command exits non-zero and prints `no configured providers; set BRAVE_API_KEY and/or EXA_API_KEY`. + ## Search news ```bash From 9f0bea0b039f0c77843ab80702fed9856a88dcb4 Mon Sep 17 00:00:00 2001 From: larock22 Date: Sun, 26 Apr 2026 15:44:33 -0500 Subject: [PATCH 29/44] T008: reduce duplicate code for hygiene gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branch: all-enabled-provider-fanout Changes Summary: src/app/fanout_search_service.rs | 7 +----- src/app/search_service.rs | 7 +----- src/domain/provider.rs | 2 +- src/providers/exa/mapper.rs | 50 +++++++++++++++++++--------------------- 4 files changed, 27 insertions(+), 39 deletions(-) Detailed Diffs (truncated to 200 lines): diff --git a/src/app/fanout_search_service.rs b/src/app/fanout_search_service.rs index 16ae62e..62416fe 100644 --- a/src/app/fanout_search_service.rs +++ b/src/app/fanout_search_service.rs @@ -63,12 +63,7 @@ mod tests { fn capabilities(&self) -> ProviderCapabilities { ProviderCapabilities { web: true, - news: false, - images: false, - videos: false, - pagination: false, - safe_search: false, - time_range_filter: false, + ..ProviderCapabilities::default() } } diff --git a/src/app/search_service.rs b/src/app/search_service.rs index 91bd6d1..f96d783 100644 --- a/src/app/search_service.rs +++ b/src/app/search_service.rs @@ -43,12 +43,7 @@ mod tests { fn capabilities(&self) -> ProviderCapabilities { ProviderCapabilities { web: true, - news: false, - images: false, - videos: false, - pagination: false, - safe_search: false, - time_range_filter: false, + ..ProviderCapabilities::default() } } diff --git a/src/domain/provider.rs b/src/domain/provider.rs index 88dab2a..f6009d6 100644 --- a/src/domain/provider.rs +++ b/src/domain/provider.rs @@ -3,7 +3,7 @@ use crate::domain::query::SearchQuery; use crate::domain::result::SearchResponse; use async_trait::async_trait; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] #[allow(dead_code)] pub struct ProviderCapabilities { pub web: bool, diff --git a/src/providers/exa/mapper.rs b/src/providers/exa/mapper.rs index f0c470d..64e53d4 100644 --- a/src/providers/exa/mapper.rs +++ b/src/providers/exa/mapper.rs @@ -7,28 +7,32 @@ const SNIPPET_DISPLAY_MAX_CHARS: usize = 500; const HIGHLIGHT_JOIN: &str = " … "; pub fn map_web_response(query: &str, dto: ExaSearchResponse) -> SearchResponse { - SearchResponse { - query: query.to_string(), - provider: "exa".to_string(), - total_estimated: None, - next_page: None, - results: dto - .results - .into_iter() - .map(|result| { - let snippet = preferred_snippet(&result); - SearchResult::Web(WebResult { - title: result.title.unwrap_or_default(), - url: result.url.unwrap_or_default(), - snippet, - display_url: None, - }) - }) - .collect(), - } + map_response(query, dto, |result, snippet| { + SearchResult::Web(WebResult { + title: result.title.unwrap_or_default(), + url: result.url.unwrap_or_default(), + snippet, + display_url: None, + }) + }) } pub fn map_news_response(query: &str, dto: ExaSearchResponse) -> SearchResponse { + map_response(query, dto, |result, snippet| { + SearchResult::News(NewsResult { + title: result.title.unwrap_or_default(), + url: result.url.unwrap_or_default(), + snippet, + source: result.author, + published_at: result.published_date, + }) + }) +} + +fn map_response(query: &str, dto: ExaSearchResponse, map_result: F) -> SearchResponse +where + F: Fn(ExaResult, Option) -> SearchResult, +{ SearchResponse { query: query.to_string(), provider: "exa".to_string(), @@ -39,13 +43,7 @@ pub fn map_news_response(query: &str, dto: ExaSearchResponse) -> SearchResponse .into_iter() .map(|result| { let snippet = preferred_snippet(&result); - SearchResult::News(NewsResult { - title: result.title.unwrap_or_default(), - url: result.url.unwrap_or_default(), - snippet, - source: result.author, - published_at: result.published_date, - }) + map_result(result, snippet) }) .collect(), } --- src/app/fanout_search_service.rs | 7 +---- src/app/search_service.rs | 7 +---- src/domain/provider.rs | 2 +- src/providers/exa/mapper.rs | 50 +++++++++++++++----------------- 4 files changed, 27 insertions(+), 39 deletions(-) diff --git a/src/app/fanout_search_service.rs b/src/app/fanout_search_service.rs index 16ae62e..62416fe 100644 --- a/src/app/fanout_search_service.rs +++ b/src/app/fanout_search_service.rs @@ -63,12 +63,7 @@ mod tests { fn capabilities(&self) -> ProviderCapabilities { ProviderCapabilities { web: true, - news: false, - images: false, - videos: false, - pagination: false, - safe_search: false, - time_range_filter: false, + ..ProviderCapabilities::default() } } diff --git a/src/app/search_service.rs b/src/app/search_service.rs index 91bd6d1..f96d783 100644 --- a/src/app/search_service.rs +++ b/src/app/search_service.rs @@ -43,12 +43,7 @@ mod tests { fn capabilities(&self) -> ProviderCapabilities { ProviderCapabilities { web: true, - news: false, - images: false, - videos: false, - pagination: false, - safe_search: false, - time_range_filter: false, + ..ProviderCapabilities::default() } } diff --git a/src/domain/provider.rs b/src/domain/provider.rs index 88dab2a..f6009d6 100644 --- a/src/domain/provider.rs +++ b/src/domain/provider.rs @@ -3,7 +3,7 @@ use crate::domain::query::SearchQuery; use crate::domain::result::SearchResponse; use async_trait::async_trait; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] #[allow(dead_code)] pub struct ProviderCapabilities { pub web: bool, diff --git a/src/providers/exa/mapper.rs b/src/providers/exa/mapper.rs index f0c470d..64e53d4 100644 --- a/src/providers/exa/mapper.rs +++ b/src/providers/exa/mapper.rs @@ -7,28 +7,32 @@ const SNIPPET_DISPLAY_MAX_CHARS: usize = 500; const HIGHLIGHT_JOIN: &str = " … "; pub fn map_web_response(query: &str, dto: ExaSearchResponse) -> SearchResponse { - SearchResponse { - query: query.to_string(), - provider: "exa".to_string(), - total_estimated: None, - next_page: None, - results: dto - .results - .into_iter() - .map(|result| { - let snippet = preferred_snippet(&result); - SearchResult::Web(WebResult { - title: result.title.unwrap_or_default(), - url: result.url.unwrap_or_default(), - snippet, - display_url: None, - }) - }) - .collect(), - } + map_response(query, dto, |result, snippet| { + SearchResult::Web(WebResult { + title: result.title.unwrap_or_default(), + url: result.url.unwrap_or_default(), + snippet, + display_url: None, + }) + }) } pub fn map_news_response(query: &str, dto: ExaSearchResponse) -> SearchResponse { + map_response(query, dto, |result, snippet| { + SearchResult::News(NewsResult { + title: result.title.unwrap_or_default(), + url: result.url.unwrap_or_default(), + snippet, + source: result.author, + published_at: result.published_date, + }) + }) +} + +fn map_response(query: &str, dto: ExaSearchResponse, map_result: F) -> SearchResponse +where + F: Fn(ExaResult, Option) -> SearchResult, +{ SearchResponse { query: query.to_string(), provider: "exa".to_string(), @@ -39,13 +43,7 @@ pub fn map_news_response(query: &str, dto: ExaSearchResponse) -> SearchResponse .into_iter() .map(|result| { let snippet = preferred_snippet(&result); - SearchResult::News(NewsResult { - title: result.title.unwrap_or_default(), - url: result.url.unwrap_or_default(), - snippet, - source: result.author, - published_at: result.published_date, - }) + map_result(result, snippet) }) .collect(), } From e5436b675fb20eeaa8be4d9d282f3d35c1f6f7fb Mon Sep 17 00:00:00 2001 From: larock22 Date: Sun, 26 Apr 2026 15:47:51 -0500 Subject: [PATCH 30/44] docs: record provider fan-out changelog entry Branch: main Changes Summary: CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) Detailed Diffs (truncated to 200 lines): diff --git a/CHANGELOG.md b/CHANGELOG.md index 69872ac..070741a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `--provider all` fan-out mode that queries every environment-enabled provider and renders per-provider successes and failures. (#11) - Provider registry composition layer for built-in provider registration, provider metadata discovery, and `SearchService` construction. (#7) - Environment-filtered structured tracing spans for startup, search orchestration, provider adapters, and HTTP transport. Logs are written to stderr so CLI result output stays clean. (#8) - Markdown frontmatter validation and mdBook frontmatter stripping in the canonical `just check` gate. (#9) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69872ac..070741a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `--provider all` fan-out mode that queries every environment-enabled provider and renders per-provider successes and failures. (#11) - Provider registry composition layer for built-in provider registration, provider metadata discovery, and `SearchService` construction. (#7) - Environment-filtered structured tracing spans for startup, search orchestration, provider adapters, and HTTP transport. Logs are written to stderr so CLI result output stays clean. (#8) - Markdown frontmatter validation and mdBook frontmatter stripping in the canonical `just check` gate. (#9) From dcebe3345585a43f2985e490381a376e63a82558 Mon Sep 17 00:00:00 2001 From: larock22 Date: Mon, 27 Apr 2026 00:17:26 -0500 Subject: [PATCH 31/44] test: add integration test suite for app layer, registry, and CLI Adds integration coverage for app services, provider registry behavior, and CLI error/output paths. Hardens CI hygiene dependencies and deterministic CLI test environment handling. --- .github/workflows/validate-agents.yml | 3 + Cargo.toml | 12 + src/lib.rs | 6 + src/main.rs | 15 +- src/providers/brave/config.rs | 4 + src/providers/exa/config.rs | 4 + src/transport/http.rs | 10 +- tests/common/cli.rs | 44 ++++ tests/fanout_cli_test.rs | 15 +- tests/integration/cli_test.rs | 110 +++++++++ tests/integration/provider_registry_test.rs | 259 ++++++++++++++++++++ tests/integration/search_service_test.rs | 208 ++++++++++++++++ 12 files changed, 668 insertions(+), 22 deletions(-) create mode 100644 src/lib.rs create mode 100644 tests/common/cli.rs create mode 100644 tests/integration/cli_test.rs create mode 100644 tests/integration/provider_registry_test.rs create mode 100644 tests/integration/search_service_test.rs diff --git a/.github/workflows/validate-agents.yml b/.github/workflows/validate-agents.yml index 024863c..74e0194 100644 --- a/.github/workflows/validate-agents.yml +++ b/.github/workflows/validate-agents.yml @@ -77,5 +77,8 @@ jobs: with: node-version: '22' + - name: Install ripgrep for tech debt checks + run: sudo apt-get update && sudo apt-get install -y ripgrep + - name: Run hygiene checks run: just hygiene diff --git a/Cargo.toml b/Cargo.toml index 370df1c..6fbecc9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,18 @@ chrono = { version = "0.4", default-features = false, features = ["clock", "std" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +[[test]] +name = "search_service_integration" +path = "tests/integration/search_service_test.rs" + +[[test]] +name = "provider_registry_integration" +path = "tests/integration/provider_registry_test.rs" + +[[test]] +name = "cli_integration" +path = "tests/integration/cli_test.rs" + [dev-dependencies.cargo-husky] version = "1.5.0" default-features = false diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..f199ffc --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,6 @@ +pub mod app; +pub mod bootstrap; +pub mod cli; +pub mod domain; +mod providers; +mod transport; diff --git a/src/main.rs b/src/main.rs index 370880b..89acced 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,15 +1,8 @@ -mod app; -mod bootstrap; -mod cli; -mod domain; -mod providers; -mod transport; - -use bootstrap::provider_registry::{ProviderId, ProviderRegistry}; use clap::Parser; -use cli::args::{CliArgs, CliProvider}; -use cli::output::{render_fanout_text, render_text}; -use domain::query::SearchQuery; +use sophon_cli::bootstrap::provider_registry::{ProviderId, ProviderRegistry}; +use sophon_cli::cli::args::{CliArgs, CliProvider}; +use sophon_cli::cli::output::{render_fanout_text, render_text}; +use sophon_cli::domain::query::SearchQuery; use tracing_subscriber::EnvFilter; async fn run_single_provider( diff --git a/src/providers/brave/config.rs b/src/providers/brave/config.rs index da711dd..1cc5b1f 100644 --- a/src/providers/brave/config.rs +++ b/src/providers/brave/config.rs @@ -7,6 +7,10 @@ pub struct BraveConfig { impl BraveConfig { pub fn from_env() -> Result { let api_key = std::env::var("BRAVE_API_KEY")?; + if api_key.trim().is_empty() { + return Err(std::env::VarError::NotPresent); + } + Ok(Self { api_key, base_url: "https://api.search.brave.com/res/v1".to_string(), diff --git a/src/providers/exa/config.rs b/src/providers/exa/config.rs index 8a7bfc6..a2319b0 100644 --- a/src/providers/exa/config.rs +++ b/src/providers/exa/config.rs @@ -7,6 +7,10 @@ pub struct ExaConfig { impl ExaConfig { pub fn from_env() -> Result { let api_key = std::env::var("EXA_API_KEY")?; + if api_key.trim().is_empty() { + return Err(std::env::VarError::NotPresent); + } + Ok(Self { api_key, base_url: "https://api.exa.ai".to_string(), diff --git a/src/transport/http.rs b/src/transport/http.rs index c5970b1..b8fcad4 100644 --- a/src/transport/http.rs +++ b/src/transport/http.rs @@ -34,12 +34,18 @@ pub struct ReqwestHttpClient { client: Client, } -impl ReqwestHttpClient { - pub fn new() -> Self { +impl Default for ReqwestHttpClient { + fn default() -> Self { Self { client: Client::new(), } } +} + +impl ReqwestHttpClient { + pub fn new() -> Self { + Self::default() + } async fn decode_response(response: Response) -> Result where diff --git a/tests/common/cli.rs b/tests/common/cli.rs new file mode 100644 index 0000000..18b6106 --- /dev/null +++ b/tests/common/cli.rs @@ -0,0 +1,44 @@ +#![allow(dead_code)] + +use std::process::{Command, Output}; + +pub fn run_cli(args: &[&str]) -> Output { + cli_command(args) + .env_remove("RUST_LOG") + .output() + .expect("sophon-cli runs") +} + +pub fn run_cli_without_keys(args: &[&str]) -> Output { + cli_command(args) + .env("BRAVE_API_KEY", "") + .env("EXA_API_KEY", "") + .env_remove("RUST_LOG") + .current_dir(std::env::temp_dir()) + .output() + .expect("sophon-cli runs") +} + +fn cli_command(args: &[&str]) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_sophon-cli")); + command.args(args); + command +} + +pub fn stdout_text(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).into_owned() +} + +pub fn stderr_text(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +pub fn assert_stdout_empty(output: &Output) { + let stdout = stdout_text(output); + assert!(stdout.is_empty(), "unexpected stdout: {stdout}"); +} + +pub fn assert_stderr_empty(output: &Output) { + let stderr = stderr_text(output); + assert!(stderr.is_empty(), "unexpected stderr: {stderr}"); +} diff --git a/tests/fanout_cli_test.rs b/tests/fanout_cli_test.rs index 201edfb..5fe505a 100644 --- a/tests/fanout_cli_test.rs +++ b/tests/fanout_cli_test.rs @@ -1,17 +1,14 @@ -use std::process::Command; +#[path = "common/cli.rs"] +mod cli; #[test] fn provider_all_without_config_exits_nonzero_with_no_provider_error() { - let output = Command::new(env!("CARGO_BIN_EXE_sophon-cli")) - .args(["rust", "--provider", "all"]) - .env_remove("BRAVE_API_KEY") - .env_remove("EXA_API_KEY") - .current_dir(std::env::temp_dir()) - .output() - .expect("sophon-cli runs"); + let output = cli::run_cli_without_keys(&["rust", "--provider", "all"]); assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); + cli::assert_stdout_empty(&output); + + let stderr = cli::stderr_text(&output); assert!( stderr.contains("no configured providers"), "stderr did not contain no configured providers: {stderr}" diff --git a/tests/integration/cli_test.rs b/tests/integration/cli_test.rs new file mode 100644 index 0000000..bff1df7 --- /dev/null +++ b/tests/integration/cli_test.rs @@ -0,0 +1,110 @@ +#[path = "../common/cli.rs"] +mod cli; + +use clap::Parser; +use sophon_cli::cli::args::{CliArgs, CliProvider, CliSafeSearch, CliSearchType}; + +#[test] +fn cli_about_flag_prints_description() { + let output = cli::run_cli(&["--about"]); + + assert!(output.status.success()); + cli::assert_stderr_empty(&output); + + let stdout = cli::stdout_text(&output); + assert!(stdout.contains("sophon-cli")); + assert!(stdout.contains("Three-Body Problem")); + assert!(stdout.contains("Brave Search")); + assert!(stdout.contains("Exa")); +} + +#[test] +fn cli_help_flag_prints_usage() { + let output = cli::run_cli(&["--help"]); + + assert!(output.status.success()); + cli::assert_stderr_empty(&output); + + let stdout = cli::stdout_text(&output); + assert!(stdout.contains("--provider")); + assert!(stdout.contains("--search-type")); + assert!(stdout.contains("--limit")); + assert!(stdout.contains("--about")); + assert!(stdout.contains("--safe-search")); +} + +#[test] +fn cli_missing_query_exits_with_error() { + let output = cli::run_cli(&[]); + + assert!(!output.status.success()); + cli::assert_stdout_empty(&output); + + let stderr = cli::stderr_text(&output); + assert!( + stderr.contains("missing query"), + "stderr did not contain missing query: {stderr}" + ); +} + +#[test] +fn cli_brave_provider_without_key_exits_with_provider_unavailable_error() { + let output = cli::run_cli_without_keys(&["rust", "--provider", "brave"]); + + assert_explicit_provider_unavailable(output, "brave"); +} + +#[test] +fn cli_exa_provider_without_key_exits_with_provider_unavailable_error() { + let output = cli::run_cli_without_keys(&["rust", "--provider", "exa"]); + + assert_explicit_provider_unavailable(output, "exa"); +} + +#[test] +fn cli_with_explicit_arguments_parses_correctly() { + let args = CliArgs::try_parse_from([ + "sophon-cli", + "rust search", + "--provider", + "all", + "--search-type", + "news", + "--limit", + "3", + "--safe-search", + "strict", + "--country", + "US", + "--language", + "en", + ]) + .expect("explicit args parse"); + + assert_eq!(args.query.as_deref(), Some("rust search")); + assert_eq!(args.provider, CliProvider::All); + assert_eq!(args.search_type, CliSearchType::News); + assert_eq!(args.limit, Some(3)); + assert_eq!(args.safe_search, Some(CliSafeSearch::Strict)); + assert_eq!(args.country.as_deref(), Some("US")); + assert_eq!(args.language.as_deref(), Some("en")); +} + +fn assert_explicit_provider_unavailable(output: std::process::Output, provider: &str) { + assert!(!output.status.success()); + cli::assert_stdout_empty(&output); + + let stderr = cli::stderr_text(&output); + assert!( + stderr.contains(&format!("provider `{provider}` is unavailable")), + "stderr did not contain {provider} unavailable error: {stderr}" + ); + assert!( + stderr.contains("configured providers: []"), + "stderr did not include configured provider list: {stderr}" + ); + assert!( + !stderr.contains("no configured providers") && !stderr.contains("NoProvidersAvailable"), + "explicit provider should not use fan-out no-provider error: {stderr}" + ); +} diff --git a/tests/integration/provider_registry_test.rs b/tests/integration/provider_registry_test.rs new file mode 100644 index 0000000..7301f65 --- /dev/null +++ b/tests/integration/provider_registry_test.rs @@ -0,0 +1,259 @@ +use async_trait::async_trait; +use sophon_cli::bootstrap::provider_registry::{ + BuildSearchServiceError, ProviderBuilder, ProviderId, ProviderRegistry, +}; +use sophon_cli::domain::error::SearchError; +use sophon_cli::domain::provider::{ProviderCapabilities, SearchProvider}; +use sophon_cli::domain::query::SearchQuery; +use sophon_cli::domain::result::SearchResponse; +use sophon_cli::domain::types::SearchType; +use std::ffi::OsString; +use std::sync::{Mutex, MutexGuard, OnceLock}; + +struct NamedProvider { + name: &'static str, +} + +#[async_trait] +impl SearchProvider for NamedProvider { + fn id(&self) -> String { + self.name.to_string() + } + + fn capabilities(&self) -> ProviderCapabilities { + ProviderCapabilities::default() + } + + async fn search(&self, query: &SearchQuery) -> Result { + Ok(SearchResponse { + query: query.text.clone(), + provider: self.name.to_string(), + results: vec![], + total_estimated: None, + next_page: None, + }) + } +} + +struct EnvGuard { + _lock: MutexGuard<'static, ()>, + brave: Option, + exa: Option, +} + +impl EnvGuard { + fn set(brave: Option<&str>, exa: Option<&str>) -> Self { + let lock = env_lock().lock().unwrap(); + let guard = Self { + _lock: lock, + brave: std::env::var_os("BRAVE_API_KEY"), + exa: std::env::var_os("EXA_API_KEY"), + }; + + set_env_var("BRAVE_API_KEY", brave); + set_env_var("EXA_API_KEY", exa); + guard + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + restore_saved_env_var("BRAVE_API_KEY", self.brave.take()); + restore_saved_env_var("EXA_API_KEY", self.exa.take()); + } +} + +fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +fn set_env_var(name: &str, value: Option<&str>) { + match value { + Some(value) => unsafe { + std::env::set_var(name, value); + }, + None => unsafe { + std::env::remove_var(name); + }, + } +} + +fn restore_saved_env_var(name: &str, saved_value: Option) { + if let Some(saved_value) = saved_value { + unsafe { std::env::set_var(name, saved_value) }; + } else { + unsafe { std::env::remove_var(name) }; + } +} + +fn provider_builder(name: &'static str) -> ProviderBuilder { + Box::new(move || Box::new(NamedProvider { name })) +} + +fn search_query(text: &str) -> SearchQuery { + SearchQuery { + text: text.to_string(), + search_type: SearchType::Web, + limit: None, + offset: None, + safe_search: None, + country: None, + language: None, + time_range: None, + } +} + +#[test] +fn empty_registry_build_fails_with_provider_unavailable() { + let registry = ProviderRegistry::empty(); + let result = registry.build(ProviderId::Brave); + + match result { + Err(BuildSearchServiceError::ProviderUnavailable { + provider, + available, + }) => { + assert_eq!(provider, ProviderId::Brave); + assert!(available.is_empty()); + } + other => panic!( + "expected ProviderUnavailable error, got {:?}", + other.map(|_| ()) + ), + } +} + +#[test] +fn empty_registry_build_all_enabled_fails_with_no_providers() { + let registry = ProviderRegistry::empty(); + let result = registry.build_all_enabled(); + + match result { + Err(BuildSearchServiceError::NoProvidersAvailable) => {} + other => panic!( + "expected NoProvidersAvailable error, got {:?}", + other.map(|_| ()) + ), + } +} + +#[test] +fn registered_provider_is_available() { + let mut registry = ProviderRegistry::empty(); + registry.register(ProviderId::Brave, provider_builder("stub")); + + assert_eq!(registry.available_providers(), vec![ProviderId::Brave]); +} + +#[test] +fn available_providers_returned_in_stable_order() { + let mut registry = ProviderRegistry::empty(); + registry.register(ProviderId::Exa, provider_builder("exa")); + registry.register(ProviderId::Brave, provider_builder("brave")); + + assert_eq!( + registry.available_providers(), + vec![ProviderId::Brave, ProviderId::Exa] + ); +} + +#[test] +fn production_registry_without_keys_has_no_providers() { + let _env = EnvGuard::set(None, None); + + let registry = ProviderRegistry::production_from_env(); + + assert!(registry.available_providers().is_empty()); +} + +#[test] +fn production_registry_treats_empty_keys_as_unconfigured() { + let _env = EnvGuard::set(Some(" "), Some("")); + + let registry = ProviderRegistry::production_from_env(); + + assert!(registry.available_providers().is_empty()); +} + +#[test] +fn production_registry_includes_brave_only_when_only_brave_key_is_set() { + let _env = EnvGuard::set(Some("test-brave-key"), None); + + let registry = ProviderRegistry::production_from_env(); + + assert_eq!(registry.available_providers(), vec![ProviderId::Brave]); +} + +#[test] +fn production_registry_includes_exa_only_when_only_exa_key_is_set() { + let _env = EnvGuard::set(None, Some("test-exa-key")); + + let registry = ProviderRegistry::production_from_env(); + + assert_eq!(registry.available_providers(), vec![ProviderId::Exa]); +} + +#[test] +fn production_registry_includes_both_env_configured_providers_in_stable_order() { + let _env = EnvGuard::set(Some("test-brave-key"), Some("test-exa-key")); + + let registry = ProviderRegistry::production_from_env(); + + assert_eq!( + registry.available_providers(), + vec![ProviderId::Brave, ProviderId::Exa] + ); +} + +#[test] +fn production_registry_reports_explicit_provider_unavailable_when_only_other_provider_exists() { + let _env = EnvGuard::set(None, Some("test-exa-key")); + + let registry = ProviderRegistry::production_from_env(); + let result = registry.build(ProviderId::Brave); + + match result { + Err(BuildSearchServiceError::ProviderUnavailable { + provider, + available, + }) => { + assert_eq!(provider, ProviderId::Brave); + assert_eq!(available, vec![ProviderId::Exa]); + } + other => panic!( + "expected ProviderUnavailable error, got {:?}", + other.map(|_| ()) + ), + } +} + +#[tokio::test] +async fn registered_provider_builds_service_that_searches() { + let mut registry = ProviderRegistry::empty(); + registry.register(ProviderId::Brave, provider_builder("stub")); + + let service = registry.build(ProviderId::Brave).expect("build succeeds"); + let result = service + .search(search_query("integration test")) + .await + .unwrap(); + + assert_eq!(result.query, "integration test"); + assert_eq!(result.provider, "stub"); +} + +#[tokio::test] +async fn build_all_enabled_uses_stable_order() { + let mut registry = ProviderRegistry::empty(); + registry.register(ProviderId::Exa, provider_builder("exa")); + registry.register(ProviderId::Brave, provider_builder("brave")); + + let service = registry.build_all_enabled().expect("build succeeds"); + let batch = service.search_all(search_query("rust")).await; + + assert!(batch.failures.is_empty()); + assert_eq!(batch.responses.len(), 2); + assert_eq!(batch.responses[0].provider, "brave"); + assert_eq!(batch.responses[1].provider, "exa"); +} diff --git a/tests/integration/search_service_test.rs b/tests/integration/search_service_test.rs new file mode 100644 index 0000000..ef8e263 --- /dev/null +++ b/tests/integration/search_service_test.rs @@ -0,0 +1,208 @@ +use async_trait::async_trait; +use sophon_cli::app::fanout_search_service::FanoutSearchService; +use sophon_cli::app::search_service::SearchService; +use sophon_cli::domain::error::SearchError; +use sophon_cli::domain::provider::{ProviderCapabilities, SearchProvider}; +use sophon_cli::domain::query::SearchQuery; +use sophon_cli::domain::result::{SearchResponse, SearchResult, WebResult}; +use sophon_cli::domain::types::SearchType; +use std::sync::{Arc, Mutex}; + +#[derive(Clone)] +enum ProviderOutcome { + Success(SearchResponse), + Failure(&'static str), +} + +struct SpyProvider { + id: &'static str, + seen_queries: Arc>>, + outcome: ProviderOutcome, +} + +#[async_trait] +impl SearchProvider for SpyProvider { + fn id(&self) -> String { + self.id.to_string() + } + + fn capabilities(&self) -> ProviderCapabilities { + ProviderCapabilities { + web: true, + ..ProviderCapabilities::default() + } + } + + async fn search(&self, query: &SearchQuery) -> Result { + self.seen_queries.lock().unwrap().push(query.clone()); + + match &self.outcome { + ProviderOutcome::Success(response) => Ok(response.clone()), + ProviderOutcome::Failure(message) => Err(SearchError::Provider((*message).to_string())), + } + } +} + +fn query() -> SearchQuery { + SearchQuery { + text: "distributed systems".to_string(), + search_type: SearchType::Web, + limit: Some(10), + offset: None, + safe_search: None, + country: None, + language: None, + time_range: None, + } +} + +fn response(provider: &str, total_estimated: Option) -> SearchResponse { + SearchResponse { + query: "provider supplied query".to_string(), + provider: provider.to_string(), + results: vec![], + total_estimated, + next_page: None, + } +} + +fn provider( + id: &'static str, + outcome: ProviderOutcome, +) -> (SpyProvider, Arc>>) { + let seen_queries = Arc::new(Mutex::new(Vec::new())); + ( + SpyProvider { + id, + seen_queries: Arc::clone(&seen_queries), + outcome, + }, + seen_queries, + ) +} + +fn web_result(title: &str, url: &str, snippet: Option<&str>) -> SearchResult { + SearchResult::Web(WebResult { + title: title.to_string(), + url: url.to_string(), + snippet: snippet.map(str::to_string), + display_url: None, + }) +} + +#[tokio::test] +async fn search_service_passes_full_query_to_provider_and_returns_exact_response() { + let expected_query = query(); + let expected_response = SearchResponse { + query: "canonical provider query".to_string(), + provider: "mock".to_string(), + results: vec![], + total_estimated: None, + next_page: None, + }; + let (provider, seen_queries) = + provider("mock", ProviderOutcome::Success(expected_response.clone())); + let service = SearchService::new(Box::new(provider)); + + let result = service.search(expected_query.clone()).await.unwrap(); + + assert_eq!(result, expected_response); + assert_eq!(*seen_queries.lock().unwrap(), vec![expected_query]); +} + +#[tokio::test] +async fn search_service_propagates_provider_errors() { + let (provider, seen_queries) = provider("broken", ProviderOutcome::Failure("network timeout")); + let expected_query = query(); + let service = SearchService::new(Box::new(provider)); + + let error = service + .search(expected_query.clone()) + .await + .expect_err("provider error should propagate"); + + match error { + SearchError::Provider(message) => assert_eq!(message, "network timeout"), + other => panic!("expected provider error, got {other}"), + } + assert_eq!(*seen_queries.lock().unwrap(), vec![expected_query]); +} + +#[tokio::test] +async fn search_service_preserves_result_count() { + let provider_response = SearchResponse { + query: "provider supplied query".to_string(), + provider: "brave".to_string(), + results: vec![ + web_result("First", "https://example.com/1", Some("snippet one")), + web_result("Second", "https://example.com/2", None), + ], + total_estimated: Some(2), + next_page: None, + }; + let (provider, _) = provider("brave", ProviderOutcome::Success(provider_response)); + let service = SearchService::new(Box::new(provider)); + + let result = service.search(query()).await.unwrap(); + + assert_eq!(result.results.len(), 2); + assert_eq!(result.total_estimated, Some(2)); +} + +#[tokio::test] +async fn fanout_service_aggregates_multiple_providers() { + let (brave, _) = provider( + "brave", + ProviderOutcome::Success(response("brave", Some(100))), + ); + let (exa, _) = provider("exa", ProviderOutcome::Success(response("exa", Some(50)))); + let service = FanoutSearchService::new(vec![Box::new(brave), Box::new(exa)]); + + let batch = service.search_all(query()).await; + + assert_eq!(batch.query, "distributed systems"); + assert_eq!(batch.responses.len(), 2); + assert!(batch.failures.is_empty()); + assert_eq!(batch.responses[0].provider, "brave"); + assert_eq!(batch.responses[0].total_estimated, Some(100)); + assert_eq!(batch.responses[1].provider, "exa"); + assert_eq!(batch.responses[1].total_estimated, Some(50)); +} + +#[tokio::test] +async fn fanout_service_records_failures_without_short_circuiting() { + let (working, _) = provider( + "working", + ProviderOutcome::Success(response("working", None)), + ); + let (broken, _) = provider("broken", ProviderOutcome::Failure("network timeout")); + let service = FanoutSearchService::new(vec![Box::new(working), Box::new(broken)]); + + let batch = service.search_all(query()).await; + + assert_eq!(batch.responses.len(), 1); + assert_eq!(batch.failures.len(), 1); + assert_eq!(batch.responses[0].provider, "working"); + assert_eq!(batch.failures[0].provider, "broken"); +} + +#[tokio::test] +async fn fanout_service_preserves_stable_order() { + let providers: Vec> = ["alpha", "beta", "gamma"] + .into_iter() + .map(|id| { + let (provider, _) = provider(id, ProviderOutcome::Success(response(id, None))); + Box::new(provider) as Box + }) + .collect(); + let service = FanoutSearchService::new(providers); + + let batch = service.search_all(query()).await; + let ids: Vec<&str> = batch + .responses + .iter() + .map(|response| response.provider.as_str()) + .collect(); + + assert_eq!(ids, vec!["alpha", "beta", "gamma"]); +} From b35c7c5ef78ba0ff6214aa98f8b865d307437f74 Mon Sep 17 00:00:00 2001 From: larock22 Date: Mon, 27 Apr 2026 22:36:38 -0500 Subject: [PATCH 32/44] Add architecture diagram and pre-commit checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branch: main Changes Summary: .cargo-husky/hooks/pre-commit | 11 ++ sophon-cli-architecture.html | 304 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 315 insertions(+) Detailed Diffs (truncated to 200 lines): diff --git a/.cargo-husky/hooks/pre-commit b/.cargo-husky/hooks/pre-commit new file mode 100755 index 0000000..9d98b80 --- /dev/null +++ b/.cargo-husky/hooks/pre-commit @@ -0,0 +1,11 @@ +#!/bin/sh +# +# Pre-commit hook installed by cargo-husky. +# Runs fast quality checks before every commit. +set -e + +echo "[pre-commit] Running cargo fmt --check" +cargo fmt --check + +echo "[pre-commit] Running cargo clippy" +cargo clippy -- -D warnings -W clippy::complexity -W clippy::cognitive_complexity diff --git a/sophon-cli-architecture.html b/sophon-cli-architecture.html new file mode 100644 index 0000000..686eec3 --- /dev/null +++ b/sophon-cli-architecture.html @@ -0,0 +1,304 @@ + + + + + + sophon-cli Architecture Diagram + + + + +
+
+
+
+

sophon-cli Architecture

+
+

Module map and data flow from main.rs through the provider-agnostic search pipeline

+
+ +
+ + + + + + + + + + + + + + + + + args + + + + parse + + + + query + limit + + + + build + + + + build + + + + search() + + + + search_all() + + + + trait call + + + + trait call + + + + HTTP GET + + + + HTTP POST + + + + JSON + + + + JSON + + + + render + + + + init + + + + load + + + + + + Terminal + stdin / stdout / stderr + + + + + CliArgs + clap parser + + + + + main.rs + entrypoint · tokio::main + run_single_provider + + + + + output.rs + render · render_fanout + + + + + ProviderRegistry + bootstrap from env + + + + + SearchService + single provider + --- .cargo-husky/hooks/pre-commit | 11 ++ sophon-cli-architecture.html | 304 ++++++++++++++++++++++++++++++++++ 2 files changed, 315 insertions(+) create mode 100755 .cargo-husky/hooks/pre-commit create mode 100644 sophon-cli-architecture.html diff --git a/.cargo-husky/hooks/pre-commit b/.cargo-husky/hooks/pre-commit new file mode 100755 index 0000000..9d98b80 --- /dev/null +++ b/.cargo-husky/hooks/pre-commit @@ -0,0 +1,11 @@ +#!/bin/sh +# +# Pre-commit hook installed by cargo-husky. +# Runs fast quality checks before every commit. +set -e + +echo "[pre-commit] Running cargo fmt --check" +cargo fmt --check + +echo "[pre-commit] Running cargo clippy" +cargo clippy -- -D warnings -W clippy::complexity -W clippy::cognitive_complexity diff --git a/sophon-cli-architecture.html b/sophon-cli-architecture.html new file mode 100644 index 0000000..686eec3 --- /dev/null +++ b/sophon-cli-architecture.html @@ -0,0 +1,304 @@ + + + + + + sophon-cli Architecture Diagram + + + + +
+
+
+
+

sophon-cli Architecture

+
+

Module map and data flow from main.rs through the provider-agnostic search pipeline

+
+ +
+ + + + + + + + + + + + + + + + + args + + + + parse + + + + query + limit + + + + build + + + + build + + + + search() + + + + search_all() + + + + trait call + + + + trait call + + + + HTTP GET + + + + HTTP POST + + + + JSON + + + + JSON + + + + render + + + + init + + + + load + + + + + + Terminal + stdin / stdout / stderr + + + + + CliArgs + clap parser + + + + + main.rs + entrypoint · tokio::main + run_single_provider + + + + + output.rs + render · render_fanout + + + + + ProviderRegistry + bootstrap from env + + + + + SearchService + single provider + + + + + FanoutSearchService + parallel fan-out + + + + + Domain Layer + SearchProvider trait · SearchQuery + SearchResponse · error · types + + + + + BraveProvider + client · dto · mapper · config + + + + + ExaProvider + client · dto · mapper · config + + + + + ReqwestHttpClient + HttpClient trait impl + + + + + Brave Search API + api.search.brave.com + + + + + Exa API + api.exa.ai + + + + + tracing + EnvFilter → stderr + + + + + dotenvy + .env loader + + + Legend + + + CLI / Frontend + + + Application + + + Domain / Core + + + Provider / Transport + + + External API + + + Observability + + + Async call + +
+ + +
+
+
+
+

CLI Layer

+
+
    +
  • • CliArgs — clap derive parser with query, provider, and filters
  • +
  • • CliSearchType / CliSafeSearch / CliProvider value enums
  • +
  • • output.rs — render_text and render_fanout_text formatters
  • +
  • • --about flag and structured help text
  • +
+
+ +
+
+
+

Application Layer

+
+
    +
  • • main.rs — tokio::main entrypoint, run_single_provider helper
  • +
  • • ProviderRegistry — env-based builder registry (Brave + Exa)
  • +
  • • SearchService — single-provider orchestration wrapper
  • +
  • • FanoutSearchService — parallel search across all enabled providers
  • +
+
+ +
+
+
+

Provider & Transport Layer

+
+
    +
  • • BraveProvider & ExaProvider — SearchProvider trait impls
  • +
  • • Per-provider DTOs, mappers, and config modules
  • +
  • • HttpClient trait — abstracts GET/POST JSON transport
  • +
  • • ReqwestHttpClient — auth, rate-limit, and decode error handling
  • +
+
+
+ + +
+ + From 523053c4b429e7fb7006aa89ad3fec5a0c12c3b9 Mon Sep 17 00:00:00 2001 From: larock22 Date: Tue, 28 Apr 2026 16:20:47 -0500 Subject: [PATCH 33/44] chore: pre org --- .DS_Store | Bin 0 -> 6148 bytes docs/SUMMARY.md | 4 + docs/dependency-architecture-map.html | 84 ++++ docs/dependency-architecture-map.md | 43 ++ docs/dependency-direction.html | 469 ++++++++++++++++++++ docs/dependency-direction.md | 61 +++ docs/ideal-dependency-architecture-map.html | 70 +++ docs/ideal-dependency-architecture-map.md | 59 +++ src/app/fanout_search_service.rs | 14 +- src/app/search_service.rs | 8 +- src/bootstrap/provider_registry.rs | 21 +- src/cli/args.rs | 11 +- src/cli/output.rs | 7 +- src/domain/mod.rs | 9 + src/domain/provider.rs | 5 +- src/domain/query.rs | 2 +- src/domain/result.rs | 3 +- src/main.rs | 30 +- src/providers/brave/client.rs | 39 +- src/providers/brave/mapper.rs | 12 +- src/providers/exa/client.rs | 33 +- src/providers/exa/mapper.rs | 10 +- src/single_provider_search.rs | 29 ++ src/transport/http.rs | 3 +- tests/integration/provider_registry_test.rs | 8 +- tests/integration/search_service_test.rs | 9 +- 26 files changed, 925 insertions(+), 118 deletions(-) create mode 100644 .DS_Store create mode 100644 docs/dependency-architecture-map.html create mode 100644 docs/dependency-architecture-map.md create mode 100644 docs/dependency-direction.html create mode 100644 docs/dependency-direction.md create mode 100644 docs/ideal-dependency-architecture-map.html create mode 100644 docs/ideal-dependency-architecture-map.md create mode 100644 src/single_provider_search.rs diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..ca2b7874ee44cef1857d5487e8c8184223131c55 GIT binary patch literal 6148 zcmeH~F$w}f3`G;&La^D=avBfd4F=H@>;(h`8(BfodXDZ-CJ2t!BJu;tpJXO1`-+{7 zi0JxuSc&u^GJ~7S(n4d3ypw~RWiQwJa2ZeM@rat$Cvn!+@Lrnz*rt#G36KB@kN^q% z5COZlVY7KvMiL+a5_l4@??Zx{=Fn2rKOG1@0zf;I-LUpq0-CG<&7q|#Dlm=dL8DcD z46(YmLsOi~p`~hV7meXV4M3`}dgXhH(h?7~0-B+w9;*1Wg-e+&OK|2Hj6Nq_|Y zjDU8VVY9|d#ohY$dRE^>)z$?L_2URHKLJSWDqg_du%B!J&7q|#Dlq;CI0gn1_$q-1 D?w=C8 literal 0 HcmV?d00001 diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index c3a111e..b65a53a 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -3,4 +3,8 @@ [Introduction](intro.md) - [Architecture](architecture.md) +- [Dependency Direction Visual](dependency-direction.md) +- [Current Dependency Architecture Map](dependency-architecture-map.md) +- [Ideal Dependency Architecture Map](ideal-dependency-architecture-map.md) +- [Import Organization Visual](import-organization.md) - [Quick Start](quickstart.md) diff --git a/docs/dependency-architecture-map.html b/docs/dependency-architecture-map.html new file mode 100644 index 0000000..3db3b49 --- /dev/null +++ b/docs/dependency-architecture-map.html @@ -0,0 +1,84 @@ + + + + + +sophon-cli Current Dependency Map + + + + + + +
+
+
current node-and-arrow map
+

Current dependency architecture map.

+

This is the current codebase as a real map: boxes are actual files/modules, arrows are source-code import dependencies. It is intentionally less abstract than the ideal map so you can compare what exists to the target shape.

+ +
+ +
+
Current mapactual files/modules, routed by architecture layer
+
+
+ + +
src/main.rsentrypoint
+
cli::argsparses flags into domain options
+
cli::outputrenders domain responses
+
single_provider_searchhelper using registry + renderer
+
bootstrap::provider_registrybuilds services/providers/transport
+
app::SearchServicesingle provider orchestration
+
app::FanoutSearchServicemulti-provider orchestration
+
brave::clientdomain + HttpClient + local modules
+
brave::mapperDTOs → domain results
+
exa::clientdomain + HttpClient + local modules
+
exa::mapperDTOs → domain results
+
transport::httpHTTP + domain SearchError
+
domain::errorSearchError
+
domain::querySearchQuery
+
domain::providerSearchProvider trait
+
domain::resultSearchResponse/results
+
domain::typesSearchType/SafeSearch
+
+
+ +

How to read this map

+
Green arrows

Main architectural flow: entry → CLI/bootstrap → app → adapters → domain.

+
Blue arrows

Supporting imports, mostly direct imports into the domain facade/types.

+
Dashed arrows

Local sibling dependencies, like provider client → mapper and domain file → domain sibling.

+
+
+ + diff --git a/docs/dependency-architecture-map.md b/docs/dependency-architecture-map.md new file mode 100644 index 0000000..1bda19e --- /dev/null +++ b/docs/dependency-architecture-map.md @@ -0,0 +1,43 @@ +--- +title: "Current Dependency Architecture Map" +when_to_read: + - "When you need to see the current Rust import/dependency direction as a clean architecture map." + - "When comparing the current module import shape against the ideal architecture map." +summary: "Current architecture-style map of sophon-cli module import direction, shown in the same visual language as the ideal dependency architecture map." +ontology_relations: + - relation: "part_of" + target: "docs/SUMMARY.md" + note: "Belongs to the mdBook documentation set." + - relation: "explains" + target: "docs/architecture.md" + note: "Visualizes the concrete import direction behind the architecture layers." + - relation: "compares_with" + target: "docs/ideal-dependency-architecture-map.md" + note: "Pairs current architecture with ideal target architecture." +--- + +# Current Dependency Architecture Map + +This is the current/actual Rust import shape, drawn as a clean architecture map. + +Read every dependency as: + +```text +importer -> imported dependency +``` + +Open the standalone visual page: + +[Current dependency architecture map](dependency-architecture-map.html) + +Compare with: + +[Ideal dependency architecture map](ideal-dependency-architecture-map.md) + + + +If the embedded frame is cramped, open the standalone page above. diff --git a/docs/dependency-direction.html b/docs/dependency-direction.html new file mode 100644 index 0000000..3175c2a --- /dev/null +++ b/docs/dependency-direction.html @@ -0,0 +1,469 @@ + + + + + +sophon-cli Dependency Direction + + + + + + +
+
+
sophon-cli dependency direction
+

Small maps beat giant hairballs.

+

+ cargo visualize --all-deps shows every transitive dependency. That is useful for audits, but noisy for orientation. + This page keeps only the two maps maintainers usually need: direct external crates and internal module direction. +

+ +
+ +
+
+
+
+ +

Direct Cargo dependencies

+
+

+ Read this as: sophon-cli depends on each crate below. Transitive crates are intentionally hidden. +

+
+ +
+ sophon-cli + package root from Cargo.toml +
+
direct dependency direction
+ +
+
+

Runtime + HTTP

+
    +
  • tokioasync runtime
  • +
  • reqwestHTTP client
  • +
+
+ +
+

Data encoding

+
    +
  • serdederive + DTOs
  • +
  • serde_jsonJSON body/value
  • +
  • chronotimestamps
  • +
+
+ +
+

CLI interface

+
    +
  • clapargument parser
  • +
  • dotenvy.env loading
  • +
+
+ +
+

Errors + traits

+
    +
  • thiserrorerror enums
  • +
  • async-traitasync provider trait
  • +
+
+ +
+

Observability

+
    +
  • tracingstructured spans
  • +
  • tracing-subscriberstderr logging
  • +
+
+ +
+

Dev-only

+
    +
  • cargo-huskylocal hooks
  • +
+
+
+ +
+ runtime/http + data + CLI + support + dev-only +
+
+ +
+
+
+ +

Internal layer direction

+
+

+ Read this top-to-bottom: upper code may depend on lower code. Lower layers should not import upward. +

+
+ +
+
+
Entry + CLIoutermost user surface
+
+
src/main.rssrc/cli/args.rssrc/cli/output.rssrc/single_provider_search.rs
+

Parses CLI flags, maps them into domain queries, and prints rendered output.

+
+
+
depends on ↓
+ +
+
Bootstrapcomposition root
+
+
src/bootstrap/provider_registry.rs
+

Reads provider configuration and wires provider adapters to the HTTP transport.

+
+
+
depends on ↓
+ +
+
Applicationorchestration only
+
+
src/app/search_service.rssrc/app/fanout_search_service.rs
+

Coordinates one provider or all enabled providers through domain traits.

+
+
+
depends on ↓
+ +
+
Adaptersprovider + transport edge
+
+
src/providers/brave/*src/providers/exa/*src/transport/http.rs
+

Turns provider-specific HTTP/JSON details into provider-agnostic domain results.

+
+
+
depends on ↓
+ +
+
Domainbottom, pure contracts
+
+
src/domain/query.rssrc/domain/result.rssrc/domain/provider.rssrc/domain/error.rs
+

Provider-agnostic types, errors, and traits. This layer must not know about CLI, providers, app, or transport.

+
+
+
+ +
+
Allowed

providers can import domain to implement SearchProvider.

+
Forbidden

domain should not import providers, transport, app, or cli.

+
Enforced by

tests/architecture_test.rs scans source files for boundary leaks.

+
+
+ +
+
+
+ +

Use smaller graph commands

+
+

These commands keep the visualization oriented around the question you are asking.

+
+
+
cargo tree --depth 1Terminal-only view of direct dependencies.
+
cargo visualize --depth 1 --dedup-transitive-depsSmall browser graph: direct dependencies only.
+
cargo visualize --focus reqwest --depth 2 --dedup-transitive-depsFocused browser graph: why one crate is present and what it pulls nearby.
+
cargo tree --invert tokioReverse question: who depends on this crate?
+
+
+
+
+ + diff --git a/docs/dependency-direction.md b/docs/dependency-direction.md new file mode 100644 index 0000000..4779691 --- /dev/null +++ b/docs/dependency-direction.md @@ -0,0 +1,61 @@ +--- +title: "Dependency Direction Visual" +when_to_read: + - "When you want a small visual map of sophon-cli dependency direction without the full transitive Cargo graph." + - "When checking direct crate dependencies or internal layer dependency direction." +summary: "Embedded visual explainer for sophon-cli dependency direction: grouped direct Cargo dependencies and top-to-bottom internal module direction." +ontology_relations: + - relation: "part_of" + target: "docs/SUMMARY.md" + note: "Belongs to the mdBook documentation set." + - relation: "explains" + target: "docs/architecture.md" + note: "Visualizes the architecture dependency direction described by the architecture page." +--- + +# Dependency Direction Visual + +This page is a small, organized alternative to the full `cargo visualize --all-deps` graph. + +Use it for two questions: + +1. **Which crates does `sophon-cli` directly depend on?** +2. **Which direction should internal modules depend on?** + +Open the standalone visual page: + +[Dependency direction visual](dependency-direction.html) + + + +If the embedded frame is cramped, open the standalone page above. + +## Related commands + +Direct dependencies only: + +```bash +cargo tree --depth 1 +``` + +Small browser graph: + +```bash +cargo visualize --depth 1 --dedup-transitive-deps +``` + +Focused browser graph for one crate: + +```bash +cargo visualize --focus reqwest --depth 2 --dedup-transitive-deps +``` + +Reverse dependency question: + +```bash +cargo tree --invert tokio +``` diff --git a/docs/ideal-dependency-architecture-map.html b/docs/ideal-dependency-architecture-map.html new file mode 100644 index 0000000..2d10615 --- /dev/null +++ b/docs/ideal-dependency-architecture-map.html @@ -0,0 +1,70 @@ + + + + + +sophon-cli Ideal Dependency Map + + + + + + +
+
+
ideal node-and-arrow map
+

Ideal dependency architecture map.

+

This is the target shape as an actual map: boxes are architecture responsibilities, arrows are allowed source-code dependency direction. Everything routes downward into the domain core.

+ +
+ +
+
Ideal mapclean target: composition owns concretes, app owns orchestration, domain owns contracts
+
+
+ + +
Entrypointstart program, delegate, handle exit
+
CLI Surfaceparse input + render output; domain vocabulary only
+
Bootstrapsingle composition root for provider + transport wiring
+
Application Servicesorchestrate SearchProvider trait objects
+
Provider AdaptersBrave / Exa clients, DTOs, mappers, configs
+
Transport AdapterHTTP trait + reqwest implementation
+
Domain TypesSearchQuery, SearchResponse, result model
+
Domain TraitsSearchProvider + ProviderCapabilities
+
Domain Errors / EnumsSearchError, SearchType, SafeSearch, TimeRange
+
+
+ +

Ideal import rules shown by the map

+
App sees traits, not providers

app imports SearchProvider, not BraveProvider or ExaProvider.

+
Bootstrap sees concretes

bootstrap is allowed to import app services, providers, transport, and domain.

+
Domain has no upward arrows

The core imports no CLI, app, provider, or transport modules.

+
+
+ + diff --git a/docs/ideal-dependency-architecture-map.md b/docs/ideal-dependency-architecture-map.md new file mode 100644 index 0000000..78d01de --- /dev/null +++ b/docs/ideal-dependency-architecture-map.md @@ -0,0 +1,59 @@ +--- +title: "Ideal Dependency Architecture Map" +when_to_read: + - "When you need the clean target dependency architecture for sophon-cli." + - "When deciding whether a Rust import points in the intended direction." +summary: "Clean ideal architecture map for sophon-cli showing the allowed top-to-bottom dependency direction across entrypoint, CLI, bootstrap, app, adapters, transport, and domain." +ontology_relations: + - relation: "part_of" + target: "docs/SUMMARY.md" + note: "Belongs to the mdBook documentation set." + - relation: "explains" + target: "docs/architecture.md" + note: "Visualizes the ideal dependency direction behind the architecture layers." + - relation: "compares_with" + target: "docs/dependency-architecture-map.md" + note: "Pairs ideal target architecture with the current architecture map." +--- + +# Ideal Dependency Architecture Map + +This is the clean target architecture for Rust import direction. + +Read the map top-to-bottom: + +```text +higher layer imports lower layer +``` + +The ideal shape is: + +```text +Entrypoint + ↓ +CLI surface + ↓ +Bootstrap / composition root + ↓ +Application orchestration + ↓ +Adapters: providers + transport + ↓ +Domain core +``` + +Open the standalone visual page: + +[Ideal dependency architecture map](ideal-dependency-architecture-map.html) + +Compare with: + +[Current dependency architecture map](dependency-architecture-map.md) + + + +If the embedded frame is cramped, open the standalone page above. diff --git a/src/app/fanout_search_service.rs b/src/app/fanout_search_service.rs index 62416fe..158a0d4 100644 --- a/src/app/fanout_search_service.rs +++ b/src/app/fanout_search_service.rs @@ -1,6 +1,6 @@ -use crate::domain::provider::SearchProvider; -use crate::domain::query::SearchQuery; -use crate::domain::result::{ProviderSearchFailure, SearchBatchResponse, SearchResponse}; +use crate::domain::{ + ProviderSearchFailure, SearchBatchResponse, SearchProvider, SearchQuery, SearchResponse, +}; pub struct FanoutSearchService { providers: Vec>, @@ -37,11 +37,9 @@ impl FanoutSearchService { #[cfg(test)] mod tests { use super::*; - use crate::domain::error::SearchError; - use crate::domain::provider::{ProviderCapabilities, SearchProvider}; - use crate::domain::query::SearchQuery; - use crate::domain::result::SearchResponse; - use crate::domain::types::SearchType; + use crate::domain::{ + ProviderCapabilities, SearchError, SearchProvider, SearchQuery, SearchResponse, SearchType, + }; use async_trait::async_trait; enum MockOutcome { diff --git a/src/app/search_service.rs b/src/app/search_service.rs index f96d783..1f8055f 100644 --- a/src/app/search_service.rs +++ b/src/app/search_service.rs @@ -1,7 +1,4 @@ -use crate::domain::error::SearchError; -use crate::domain::provider::SearchProvider; -use crate::domain::query::SearchQuery; -use crate::domain::result::SearchResponse; +use crate::domain::{SearchError, SearchProvider, SearchQuery, SearchResponse}; pub struct SearchService { provider: Box, @@ -26,8 +23,7 @@ impl SearchService { #[cfg(test)] mod tests { use super::*; - use crate::domain::provider::ProviderCapabilities; - use crate::domain::types::SearchType; + use crate::domain::{ProviderCapabilities, SearchType}; use async_trait::async_trait; struct MockProvider { diff --git a/src/bootstrap/provider_registry.rs b/src/bootstrap/provider_registry.rs index 101d99a..3427ae4 100644 --- a/src/bootstrap/provider_registry.rs +++ b/src/bootstrap/provider_registry.rs @@ -1,13 +1,12 @@ use std::collections::HashMap; use std::fmt; -use crate::app::fanout_search_service::FanoutSearchService; -use crate::app::search_service::SearchService; -use crate::domain::provider::SearchProvider; -use crate::providers::brave::client::BraveProvider; -use crate::providers::brave::config::BraveConfig; -use crate::providers::exa::client::ExaProvider; -use crate::providers::exa::config::ExaConfig; +use crate::app::{fanout_search_service::FanoutSearchService, search_service::SearchService}; +use crate::domain::SearchProvider; +use crate::providers::{ + brave::{client::BraveProvider, config::BraveConfig}, + exa::{client::ExaProvider, config::ExaConfig}, +}; use crate::transport::http::ReqwestHttpClient; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -125,11 +124,9 @@ impl ProviderRegistry { #[cfg(test)] mod tests { use super::*; - use crate::domain::error::SearchError; - use crate::domain::provider::ProviderCapabilities; - use crate::domain::query::SearchQuery; - use crate::domain::result::SearchResponse; - use crate::domain::types::SearchType; + use crate::domain::{ + ProviderCapabilities, SearchError, SearchQuery, SearchResponse, SearchType, + }; use async_trait::async_trait; use std::ffi::OsString; use std::sync::{Mutex, OnceLock}; diff --git a/src/cli/args.rs b/src/cli/args.rs index 0e1b8ce..4918979 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -1,6 +1,7 @@ -use crate::domain::types::SearchType; use clap::{Parser, ValueEnum}; +use crate::domain::{SafeSearch, SearchType}; + #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum CliSearchType { Web, @@ -27,12 +28,12 @@ pub enum CliSafeSearch { Strict, } -impl From for crate::domain::types::SafeSearch { +impl From for SafeSearch { fn from(val: CliSafeSearch) -> Self { match val { - CliSafeSearch::Off => crate::domain::types::SafeSearch::Off, - CliSafeSearch::Moderate => crate::domain::types::SafeSearch::Moderate, - CliSafeSearch::Strict => crate::domain::types::SafeSearch::Strict, + CliSafeSearch::Off => SafeSearch::Off, + CliSafeSearch::Moderate => SafeSearch::Moderate, + CliSafeSearch::Strict => SafeSearch::Strict, } } } diff --git a/src/cli/output.rs b/src/cli/output.rs index a3a695d..4a56df0 100644 --- a/src/cli/output.rs +++ b/src/cli/output.rs @@ -1,4 +1,4 @@ -use crate::domain::result::{SearchBatchResponse, SearchResponse, SearchResult}; +use crate::domain::{SearchBatchResponse, SearchResponse, SearchResult}; pub fn render_text(response: &SearchResponse) -> String { let mut lines = vec![ @@ -73,8 +73,9 @@ pub fn render_fanout_text(response: &SearchBatchResponse) -> String { #[cfg(test)] mod tests { use super::*; - use crate::domain::error::SearchError; - use crate::domain::result::*; + use crate::domain::{ + ImageResult, NewsResult, ProviderSearchFailure, SearchError, VideoResult, WebResult, + }; #[test] fn test_render_text_mixed_results() { diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 8a088c3..964cc7b 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -3,3 +3,12 @@ pub mod provider; pub mod query; pub mod result; pub mod types; + +pub use error::SearchError; +pub use provider::{ProviderCapabilities, SearchProvider}; +pub use query::SearchQuery; +pub use result::{ + ImageResult, NewsResult, PageToken, ProviderSearchFailure, SearchBatchResponse, SearchResponse, + SearchResult, VideoResult, WebResult, +}; +pub use types::{SafeSearch, SearchType, TimeRange}; diff --git a/src/domain/provider.rs b/src/domain/provider.rs index f6009d6..a4fad4a 100644 --- a/src/domain/provider.rs +++ b/src/domain/provider.rs @@ -1,8 +1,7 @@ -use crate::domain::error::SearchError; -use crate::domain::query::SearchQuery; -use crate::domain::result::SearchResponse; use async_trait::async_trait; +use super::{SearchError, SearchQuery, SearchResponse}; + #[derive(Debug, Clone, Default)] #[allow(dead_code)] pub struct ProviderCapabilities { diff --git a/src/domain/query.rs b/src/domain/query.rs index 2fc02f6..6127932 100644 --- a/src/domain/query.rs +++ b/src/domain/query.rs @@ -1,4 +1,4 @@ -use crate::domain::types::*; +use super::{SafeSearch, SearchType, TimeRange}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct SearchQuery { diff --git a/src/domain/result.rs b/src/domain/result.rs index 550af8f..290aa1b 100644 --- a/src/domain/result.rs +++ b/src/domain/result.rs @@ -1,4 +1,4 @@ -use crate::domain::error::SearchError; +use super::SearchError; #[derive(Debug, Clone, PartialEq, Eq)] pub struct PageToken(pub String); @@ -70,7 +70,6 @@ pub struct VideoResult { #[cfg(test)] mod tests { use super::*; - use crate::domain::error::SearchError; #[test] fn search_batch_response_can_hold_success_and_failure() { diff --git a/src/main.rs b/src/main.rs index 89acced..9a1f99b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,35 +1,13 @@ use clap::Parser; use sophon_cli::bootstrap::provider_registry::{ProviderId, ProviderRegistry}; use sophon_cli::cli::args::{CliArgs, CliProvider}; -use sophon_cli::cli::output::{render_fanout_text, render_text}; -use sophon_cli::domain::query::SearchQuery; +use sophon_cli::cli::output::render_fanout_text; +use sophon_cli::domain::SearchQuery; use tracing_subscriber::EnvFilter; -async fn run_single_provider( - registry: &ProviderRegistry, - provider_id: ProviderId, - query: SearchQuery, -) { - tracing::info!(provider = %provider_id, query = %query.text, "initializing search service"); +mod single_provider_search; - let service = registry.build(provider_id).unwrap_or_else(|error| { - tracing::error!(%error, "failed to build provider"); - eprintln!("{error}"); - std::process::exit(1); - }); - - match service.search(query).await { - Ok(response) => { - tracing::info!(result_count = response.results.len(), total_estimated = ?response.total_estimated, "search completed"); - println!("{}", render_text(&response)); - } - Err(e) => { - tracing::error!(error = %e, "search failed"); - eprintln!("Search failed: {}", e); - std::process::exit(1); - } - } -} +use single_provider_search::run_single_provider; #[tokio::main] async fn main() { diff --git a/src/providers/brave/client.rs b/src/providers/brave/client.rs index 4c4b017..b8e7109 100644 --- a/src/providers/brave/client.rs +++ b/src/providers/brave/client.rs @@ -1,14 +1,17 @@ -use crate::domain::error::SearchError; -use crate::domain::provider::{ProviderCapabilities, SearchProvider}; -use crate::domain::query::SearchQuery; -use crate::domain::result::SearchResponse; -use crate::domain::types::SearchType; -use crate::providers::brave::config::BraveConfig; -use crate::providers::brave::dto::*; -use crate::providers::brave::mapper::*; -use crate::transport::http::HttpClient; use async_trait::async_trait; +use crate::domain::{ + ProviderCapabilities, SafeSearch, SearchError, SearchProvider, SearchQuery, SearchResponse, + SearchType, TimeRange, +}; +use crate::transport::http::HttpClient; + +use super::config::BraveConfig; +use super::dto::{BraveImagesResponse, BraveNewsResponse, BraveVideosResponse, BraveWebResponse}; +use super::mapper::{ + map_images_response, map_news_response, map_videos_response, map_web_response, +}; + pub struct BraveProvider { client: C, config: BraveConfig, @@ -57,9 +60,9 @@ impl SearchProvider for BraveProvider { } if let Some(ss) = query.safe_search { let val = match ss { - crate::domain::types::SafeSearch::Off => "off", - crate::domain::types::SafeSearch::Moderate => "moderate", - crate::domain::types::SafeSearch::Strict => "strict", + SafeSearch::Off => "off", + SafeSearch::Moderate => "moderate", + SafeSearch::Strict => "strict", }; params.push(("safesearch".to_string(), val.to_string())); } @@ -71,10 +74,10 @@ impl SearchProvider for BraveProvider { } if let Some(ref tr) = query.time_range { let val = match tr { - crate::domain::types::TimeRange::Day => "day", - crate::domain::types::TimeRange::Week => "week", - crate::domain::types::TimeRange::Month => "month", - crate::domain::types::TimeRange::Year => "year", + TimeRange::Day => "day", + TimeRange::Week => "week", + TimeRange::Month => "month", + TimeRange::Year => "year", }; params.push(("freshness".to_string(), val.to_string())); } @@ -111,7 +114,7 @@ impl SearchProvider for BraveProvider { #[cfg(test)] mod tests { use super::*; - use crate::domain::types::{SafeSearch, SearchType}; + use crate::domain::SearchResult; use crate::transport::http::HttpClient; use async_trait::async_trait; use serde::Serialize; @@ -188,7 +191,7 @@ mod tests { assert_eq!(resp.total_estimated, Some(100)); assert_eq!(resp.results.len(), 1); match &resp.results[0] { - crate::domain::result::SearchResult::Web(r) => { + SearchResult::Web(r) => { assert_eq!(r.title, "Rust"); assert_eq!(r.url, "https://rust-lang.org"); } diff --git a/src/providers/brave/mapper.rs b/src/providers/brave/mapper.rs index 3cbc5da..f1b9d64 100644 --- a/src/providers/brave/mapper.rs +++ b/src/providers/brave/mapper.rs @@ -1,5 +1,8 @@ -use crate::domain::result::*; -use crate::providers::brave::dto::*; +use crate::domain::{ + ImageResult, NewsResult, SearchResponse, SearchResult, VideoResult, WebResult, +}; + +use super::dto::{BraveImagesResponse, BraveNewsResponse, BraveVideosResponse, BraveWebResponse}; pub fn map_web_response(dto: BraveWebResponse) -> SearchResponse { let query_text = dto.query.and_then(|q| q.original).unwrap_or_default(); @@ -94,6 +97,11 @@ pub fn map_videos_response(dto: BraveVideosResponse) -> SearchResponse { #[cfg(test)] mod tests { + use super::super::dto::{ + BraveImageResult, BraveImagesResponse, BraveNewsResponse, BraveNewsResult, + BraveNewsResults, BraveQuery, BraveThumbnail, BraveVideoResult, BraveVideosResponse, + BraveVideosResults, BraveWebResponse, BraveWebResult, BraveWebResults, + }; use super::*; #[test] diff --git a/src/providers/exa/client.rs b/src/providers/exa/client.rs index e1acf28..66ce041 100644 --- a/src/providers/exa/client.rs +++ b/src/providers/exa/client.rs @@ -1,17 +1,18 @@ -use crate::domain::error::SearchError; -use crate::domain::provider::{ProviderCapabilities, SearchProvider}; -use crate::domain::query::SearchQuery; -use crate::domain::result::SearchResponse; -use crate::domain::types::{SafeSearch, SearchType, TimeRange}; -use crate::providers::exa::config::ExaConfig; -use crate::providers::exa::dto::{ +use async_trait::async_trait; +use chrono::{Duration, Utc}; + +use crate::domain::{ + ProviderCapabilities, SafeSearch, SearchError, SearchProvider, SearchQuery, SearchResponse, + SearchType, TimeRange, +}; +use crate::transport::http::HttpClient; + +use super::config::ExaConfig; +use super::dto::{ ExaContentsRequest, ExaHighlightsRequest, ExaSearchRequest, ExaSearchResponse, ExaSummaryRequest, }; -use crate::providers::exa::mapper::{map_news_response, map_web_response}; -use crate::transport::http::HttpClient; -use async_trait::async_trait; -use chrono::{Duration, Utc}; +use super::mapper::{map_news_response, map_web_response}; /// Budget for Exa per-result highlights (API); mapper applies a shorter CLI-facing cap. const EXA_HIGHLIGHTS_MAX_CHARACTERS: u32 = 1200; @@ -143,13 +144,11 @@ fn published_date_window(time_range: Option<&TimeRange>) -> (Option, Opt #[cfg(test)] mod tests { + use super::super::config::ExaConfig; use super::ExaProvider; - use crate::domain::error::SearchError; - use crate::domain::provider::SearchProvider; - use crate::domain::query::SearchQuery; - use crate::domain::result::SearchResult; - use crate::domain::types::{SafeSearch, SearchType, TimeRange}; - use crate::providers::exa::config::ExaConfig; + use crate::domain::{ + SafeSearch, SearchError, SearchProvider, SearchQuery, SearchResult, SearchType, TimeRange, + }; use crate::transport::http::HttpClient; use async_trait::async_trait; use serde::Serialize; diff --git a/src/providers/exa/mapper.rs b/src/providers/exa/mapper.rs index 64e53d4..8bdc4b7 100644 --- a/src/providers/exa/mapper.rs +++ b/src/providers/exa/mapper.rs @@ -1,5 +1,6 @@ -use crate::domain::result::{NewsResult, SearchResponse, SearchResult, WebResult}; -use crate::providers::exa::dto::{ExaResult, ExaSearchResponse}; +use crate::domain::{NewsResult, SearchResponse, SearchResult, WebResult}; + +use super::dto::{ExaResult, ExaSearchResponse}; /// Maximum characters for `snippet` shown in the CLI (after join / trim). const SNIPPET_DISPLAY_MAX_CHARS: usize = 500; @@ -89,8 +90,9 @@ fn cap_snippet_chars(s: &str, max_chars: usize) -> String { #[cfg(test)] mod tests { use super::map_news_response; - use crate::domain::result::SearchResult; - use crate::providers::exa::dto::{ExaResult, ExaSearchResponse}; + use crate::domain::SearchResult; + + use super::super::dto::{ExaResult, ExaSearchResponse}; fn sample_result() -> ExaResult { ExaResult { diff --git a/src/single_provider_search.rs b/src/single_provider_search.rs new file mode 100644 index 0000000..53892eb --- /dev/null +++ b/src/single_provider_search.rs @@ -0,0 +1,29 @@ +use sophon_cli::bootstrap::provider_registry::{ProviderId, ProviderRegistry}; +use sophon_cli::cli::output::render_text; +use sophon_cli::domain::SearchQuery; + +pub async fn run_single_provider( + registry: &ProviderRegistry, + provider_id: ProviderId, + query: SearchQuery, +) { + tracing::info!(provider = %provider_id, query = %query.text, "initializing search service"); + + let service = registry.build(provider_id).unwrap_or_else(|error| { + tracing::error!(%error, "failed to build provider"); + eprintln!("{error}"); + std::process::exit(1); + }); + + match service.search(query).await { + Ok(response) => { + tracing::info!(result_count = response.results.len(), total_estimated = ?response.total_estimated, "search completed"); + println!("{}", render_text(&response)); + } + Err(e) => { + tracing::error!(error = %e, "search failed"); + eprintln!("Search failed: {}", e); + std::process::exit(1); + } + } +} diff --git a/src/transport/http.rs b/src/transport/http.rs index b8fcad4..43b3326 100644 --- a/src/transport/http.rs +++ b/src/transport/http.rs @@ -1,8 +1,9 @@ -use crate::domain::error::SearchError; use async_trait::async_trait; use reqwest::{Client, Response}; use serde::Serialize; +use crate::domain::SearchError; + #[async_trait] pub trait HttpClient: Send + Sync { async fn get_json( diff --git a/tests/integration/provider_registry_test.rs b/tests/integration/provider_registry_test.rs index 7301f65..86a34f9 100644 --- a/tests/integration/provider_registry_test.rs +++ b/tests/integration/provider_registry_test.rs @@ -2,11 +2,9 @@ use async_trait::async_trait; use sophon_cli::bootstrap::provider_registry::{ BuildSearchServiceError, ProviderBuilder, ProviderId, ProviderRegistry, }; -use sophon_cli::domain::error::SearchError; -use sophon_cli::domain::provider::{ProviderCapabilities, SearchProvider}; -use sophon_cli::domain::query::SearchQuery; -use sophon_cli::domain::result::SearchResponse; -use sophon_cli::domain::types::SearchType; +use sophon_cli::domain::{ + ProviderCapabilities, SearchError, SearchProvider, SearchQuery, SearchResponse, SearchType, +}; use std::ffi::OsString; use std::sync::{Mutex, MutexGuard, OnceLock}; diff --git a/tests/integration/search_service_test.rs b/tests/integration/search_service_test.rs index ef8e263..c4d9a04 100644 --- a/tests/integration/search_service_test.rs +++ b/tests/integration/search_service_test.rs @@ -1,11 +1,10 @@ use async_trait::async_trait; use sophon_cli::app::fanout_search_service::FanoutSearchService; use sophon_cli::app::search_service::SearchService; -use sophon_cli::domain::error::SearchError; -use sophon_cli::domain::provider::{ProviderCapabilities, SearchProvider}; -use sophon_cli::domain::query::SearchQuery; -use sophon_cli::domain::result::{SearchResponse, SearchResult, WebResult}; -use sophon_cli::domain::types::SearchType; +use sophon_cli::domain::{ + ProviderCapabilities, SearchError, SearchProvider, SearchQuery, SearchResponse, SearchResult, + SearchType, WebResult, +}; use std::sync::{Arc, Mutex}; #[derive(Clone)] From 25291ea9a163773279d91c535c0f31c2e73186e2 Mon Sep 17 00:00:00 2001 From: larock22 Date: Tue, 28 Apr 2026 16:27:15 -0500 Subject: [PATCH 34/44] chore: initialize taskplane tasks --- taskplane-tasks/CONTEXT.md | 30 ++++++ .../EXAMPLE-001-hello-world/PROMPT.md | 98 +++++++++++++++++++ .../EXAMPLE-001-hello-world/STATUS.md | 73 ++++++++++++++ .../EXAMPLE-002-parallel-smoke/PROMPT.md | 97 ++++++++++++++++++ .../EXAMPLE-002-parallel-smoke/STATUS.md | 73 ++++++++++++++ 5 files changed, 371 insertions(+) create mode 100644 taskplane-tasks/CONTEXT.md create mode 100644 taskplane-tasks/EXAMPLE-001-hello-world/PROMPT.md create mode 100644 taskplane-tasks/EXAMPLE-001-hello-world/STATUS.md create mode 100644 taskplane-tasks/EXAMPLE-002-parallel-smoke/PROMPT.md create mode 100644 taskplane-tasks/EXAMPLE-002-parallel-smoke/STATUS.md diff --git a/taskplane-tasks/CONTEXT.md b/taskplane-tasks/CONTEXT.md new file mode 100644 index 0000000..74e3929 --- /dev/null +++ b/taskplane-tasks/CONTEXT.md @@ -0,0 +1,30 @@ +# General — Context + +**Last Updated:** 2026-04-28 +**Status:** Active +**Next Task ID:** TP-002 + +--- + +## Current State + +This is the default task area for sophon. Tasks that don't belong +to a specific domain area are created here. + +Taskplane is configured and ready for task execution. Use `/orch all` for +parallel batch execution or `/orch ` for a single task. + +--- + +## Key Files + +| Category | Path | +|----------|------| +| Tasks | `taskplane-tasks/` | +| Config | `.pi/taskplane-config.json` | + +--- + +## Technical Debt / Future Work + +_Items discovered during task execution are logged here by agents._ diff --git a/taskplane-tasks/EXAMPLE-001-hello-world/PROMPT.md b/taskplane-tasks/EXAMPLE-001-hello-world/PROMPT.md new file mode 100644 index 0000000..d812d67 --- /dev/null +++ b/taskplane-tasks/EXAMPLE-001-hello-world/PROMPT.md @@ -0,0 +1,98 @@ +# Task: EXAMPLE-001 — Hello World + +**Created:** 2026-04-28 +**Size:** S + +## Review Level: 0 (None) + +**Assessment:** Trivial single-file task to verify Taskplane is working. +**Score:** 0/8 — Blast radius: 0, Pattern novelty: 0, Security: 0, Reversibility: 0 + +## Canonical Task Folder + +``` +taskplane-tasks/EXAMPLE-001-hello-world/ +├── PROMPT.md ← This file (immutable above --- divider) +├── STATUS.md ← Execution state (worker updates this) +├── .reviews/ ← Reviewer output (task-runner creates this) +└── .DONE ← Created when complete +``` + +## Mission + +Create a simple `hello-taskplane.md` file in the project root to verify that +Taskplane task execution is working correctly. This is a smoke test — if the +worker can read this prompt, create the file, checkpoint progress, and mark the +task done, the installation is healthy. + +## Expected File Content + +`hello-taskplane.md` should include: + +- A title line (for example: `# Hello from Taskplane`) +- A line containing the task ID: `EXAMPLE-001` +- A line containing today's date + +## Dependencies + +- **None** + +## Context to Read First + +_No additional context needed._ + +## Environment + +- **Workspace:** Project root +- **Services required:** None + +## File Scope + +- `hello-taskplane.md` + +## Steps + +### Step 0: Preflight + +- [ ] Verify this PROMPT.md is readable +- [ ] Verify STATUS.md exists in the same folder + +### Step 1: Create Hello File + +- [ ] Create `hello-taskplane.md` in the project root +- [ ] Add a title plus lines containing today's date and task ID `EXAMPLE-001` + +### Step 2: Verification + +- [ ] Verify `hello-taskplane.md` exists and matches the expected content + +### Step 3: Delivery + + + +## Documentation Requirements + +**Must Update:** None +**Check If Affected:** None + +## Completion Criteria + +- [ ] `hello-taskplane.md` exists in the project root +- [ ] `hello-taskplane.md` includes a title, task ID (`EXAMPLE-001`), and current date + +## Git Commit Convention + +- **Implementation:** `feat(EXAMPLE-001): description` +- **Checkpoints:** `checkpoint: EXAMPLE-001 description` + +## Do NOT + +- Modify any existing project files +- Create files outside the project root +- Over-engineer this — it's a smoke test + +--- + +## Amendments (Added During Execution) + + diff --git a/taskplane-tasks/EXAMPLE-001-hello-world/STATUS.md b/taskplane-tasks/EXAMPLE-001-hello-world/STATUS.md new file mode 100644 index 0000000..af27b02 --- /dev/null +++ b/taskplane-tasks/EXAMPLE-001-hello-world/STATUS.md @@ -0,0 +1,73 @@ +# EXAMPLE-001: Hello World — Status + +**Current Step:** Not Started +**Status:** 🔵 Ready for Execution +**Last Updated:** 2026-04-28 +**Review Level:** 0 +**Review Counter:** 0 +**Iteration:** 0 +**Size:** S + +--- + +### Step 0: Preflight +**Status:** ⬜ Not Started + +- [ ] Verify PROMPT.md is readable +- [ ] Verify STATUS.md exists + +--- + +### Step 1: Create Hello File +**Status:** ⬜ Not Started + +- [ ] Create `hello-taskplane.md` in project root +- [ ] Add title, date, and task ID (EXAMPLE-001) + +--- + +### Step 2: Verification +**Status:** ⬜ Not Started + +- [ ] Verify file exists and matches expected content + +--- + +### Step 3: Delivery +**Status:** ⬜ Not Started + + + +--- + +## Reviews + +| # | Type | Step | Verdict | File | +|---|------|------|---------|------| + +--- + +## Discoveries + +| Discovery | Disposition | Location | +|-----------|-------------|----------| + +--- + +## Execution Log + +| Timestamp | Action | Outcome | +|-----------|--------|---------| +| 2026-04-28 | Task staged | PROMPT.md and STATUS.md created | + +--- + +## Blockers + +*None* + +--- + +## Notes + +*This is an example task created by `taskplane init`. Delete it after verifying your setup works.* diff --git a/taskplane-tasks/EXAMPLE-002-parallel-smoke/PROMPT.md b/taskplane-tasks/EXAMPLE-002-parallel-smoke/PROMPT.md new file mode 100644 index 0000000..1a8137c --- /dev/null +++ b/taskplane-tasks/EXAMPLE-002-parallel-smoke/PROMPT.md @@ -0,0 +1,97 @@ +# Task: EXAMPLE-002 — Parallel Smoke + +**Created:** 2026-04-28 +**Size:** S + +## Review Level: 0 (None) + +**Assessment:** Trivial parallel-safe smoke task to demonstrate orchestrator lanes. +**Score:** 0/8 — Blast radius: 0, Pattern novelty: 0, Security: 0, Reversibility: 0 + +## Canonical Task Folder + +``` +taskplane-tasks/EXAMPLE-002-parallel-smoke/ +├── PROMPT.md ← This file (immutable above --- divider) +├── STATUS.md ← Execution state (worker updates this) +├── .reviews/ ← Reviewer output (task-runner creates this) +└── .DONE ← Created when complete +``` + +## Mission + +Create a simple `hello-taskplane-2.md` file in the project root. This task is +intentionally independent from EXAMPLE-001 so both can run in parallel when +using `/orch`. + +## Expected File Content + +`hello-taskplane-2.md` should include: + +- A title line (for example: `# Parallel Hello from Taskplane`) +- A line containing the task ID: `EXAMPLE-002` +- A short note that this task is parallel-safe + +## Dependencies + +- **None** + +## Context to Read First + +_No additional context needed._ + +## Environment + +- **Workspace:** Project root +- **Services required:** None + +## File Scope + +- `hello-taskplane-2.md` + +## Steps + +### Step 0: Preflight + +- [ ] Verify this PROMPT.md is readable +- [ ] Verify STATUS.md exists in the same folder + +### Step 1: Create Parallel Hello File + +- [ ] Create `hello-taskplane-2.md` in the project root +- [ ] Add title plus lines containing task ID `EXAMPLE-002` and a parallel-safe note + +### Step 2: Verification + +- [ ] Verify `hello-taskplane-2.md` exists and matches the expected content + +### Step 3: Delivery + + + +## Documentation Requirements + +**Must Update:** None +**Check If Affected:** None + +## Completion Criteria + +- [ ] `hello-taskplane-2.md` exists in the project root +- [ ] `hello-taskplane-2.md` includes a title, task ID (`EXAMPLE-002`), and a parallel-safe note + +## Git Commit Convention + +- **Implementation:** `feat(EXAMPLE-002): description` +- **Checkpoints:** `checkpoint: EXAMPLE-002 description` + +## Do NOT + +- Modify any existing project files +- Create files outside the project root +- Add dependencies between EXAMPLE-001 and EXAMPLE-002 + +--- + +## Amendments (Added During Execution) + + diff --git a/taskplane-tasks/EXAMPLE-002-parallel-smoke/STATUS.md b/taskplane-tasks/EXAMPLE-002-parallel-smoke/STATUS.md new file mode 100644 index 0000000..57a11ff --- /dev/null +++ b/taskplane-tasks/EXAMPLE-002-parallel-smoke/STATUS.md @@ -0,0 +1,73 @@ +# EXAMPLE-002: Parallel Smoke — Status + +**Current Step:** Not Started +**Status:** 🔵 Ready for Execution +**Last Updated:** 2026-04-28 +**Review Level:** 0 +**Review Counter:** 0 +**Iteration:** 0 +**Size:** S + +--- + +### Step 0: Preflight +**Status:** ⬜ Not Started + +- [ ] Verify PROMPT.md is readable +- [ ] Verify STATUS.md exists + +--- + +### Step 1: Create Parallel Hello File +**Status:** ⬜ Not Started + +- [ ] Create `hello-taskplane-2.md` in project root +- [ ] Add title, task ID (EXAMPLE-002), and parallel-safe note + +--- + +### Step 2: Verification +**Status:** ⬜ Not Started + +- [ ] Verify file exists and matches expected content + +--- + +### Step 3: Delivery +**Status:** ⬜ Not Started + + + +--- + +## Reviews + +| # | Type | Step | Verdict | File | +|---|------|------|---------|------| + +--- + +## Discoveries + +| Discovery | Disposition | Location | +|-----------|-------------|----------| + +--- + +## Execution Log + +| Timestamp | Action | Outcome | +|-----------|--------|---------| +| 2026-04-28 | Task staged | PROMPT.md and STATUS.md created | + +--- + +## Blockers + +*None* + +--- + +## Notes + +*This is an example task created by `taskplane init` to demonstrate orchestrator-first onboarding.* From 760829b22a13e5b922a717dd8f27cfdfe22fcf27 Mon Sep 17 00:00:00 2001 From: larock22 Date: Tue, 28 Apr 2026 16:36:31 -0500 Subject: [PATCH 35/44] chore: cleanup --- .gitignore | 16 +++ taskplane-tasks/CONTEXT.md | 30 ------ .../EXAMPLE-001-hello-world/PROMPT.md | 98 ------------------- .../EXAMPLE-001-hello-world/STATUS.md | 73 -------------- .../EXAMPLE-002-parallel-smoke/PROMPT.md | 97 ------------------ .../EXAMPLE-002-parallel-smoke/STATUS.md | 73 -------------- 6 files changed, 16 insertions(+), 371 deletions(-) delete mode 100644 taskplane-tasks/CONTEXT.md delete mode 100644 taskplane-tasks/EXAMPLE-001-hello-world/PROMPT.md delete mode 100644 taskplane-tasks/EXAMPLE-001-hello-world/STATUS.md delete mode 100644 taskplane-tasks/EXAMPLE-002-parallel-smoke/PROMPT.md delete mode 100644 taskplane-tasks/EXAMPLE-002-parallel-smoke/STATUS.md diff --git a/.gitignore b/.gitignore index d93ac60..cd82032 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,19 @@ /target book/ + +# Taskplane runtime artifacts (machine-specific, do not commit) +.pi/batch-state.json +.pi/batch-history.json +.pi/lane-state-* +.pi/merge-result-* +.pi/merge-request-* +.pi/worker-conversation-* +.pi/orch-logs/ +.pi/orch-abort-signal +.pi/settings.json +.worktrees/ +.taskplane-tasks/ + +# Pi project-local packages (if using pi install -l) +.pi/npm/ diff --git a/taskplane-tasks/CONTEXT.md b/taskplane-tasks/CONTEXT.md deleted file mode 100644 index 74e3929..0000000 --- a/taskplane-tasks/CONTEXT.md +++ /dev/null @@ -1,30 +0,0 @@ -# General — Context - -**Last Updated:** 2026-04-28 -**Status:** Active -**Next Task ID:** TP-002 - ---- - -## Current State - -This is the default task area for sophon. Tasks that don't belong -to a specific domain area are created here. - -Taskplane is configured and ready for task execution. Use `/orch all` for -parallel batch execution or `/orch ` for a single task. - ---- - -## Key Files - -| Category | Path | -|----------|------| -| Tasks | `taskplane-tasks/` | -| Config | `.pi/taskplane-config.json` | - ---- - -## Technical Debt / Future Work - -_Items discovered during task execution are logged here by agents._ diff --git a/taskplane-tasks/EXAMPLE-001-hello-world/PROMPT.md b/taskplane-tasks/EXAMPLE-001-hello-world/PROMPT.md deleted file mode 100644 index d812d67..0000000 --- a/taskplane-tasks/EXAMPLE-001-hello-world/PROMPT.md +++ /dev/null @@ -1,98 +0,0 @@ -# Task: EXAMPLE-001 — Hello World - -**Created:** 2026-04-28 -**Size:** S - -## Review Level: 0 (None) - -**Assessment:** Trivial single-file task to verify Taskplane is working. -**Score:** 0/8 — Blast radius: 0, Pattern novelty: 0, Security: 0, Reversibility: 0 - -## Canonical Task Folder - -``` -taskplane-tasks/EXAMPLE-001-hello-world/ -├── PROMPT.md ← This file (immutable above --- divider) -├── STATUS.md ← Execution state (worker updates this) -├── .reviews/ ← Reviewer output (task-runner creates this) -└── .DONE ← Created when complete -``` - -## Mission - -Create a simple `hello-taskplane.md` file in the project root to verify that -Taskplane task execution is working correctly. This is a smoke test — if the -worker can read this prompt, create the file, checkpoint progress, and mark the -task done, the installation is healthy. - -## Expected File Content - -`hello-taskplane.md` should include: - -- A title line (for example: `# Hello from Taskplane`) -- A line containing the task ID: `EXAMPLE-001` -- A line containing today's date - -## Dependencies - -- **None** - -## Context to Read First - -_No additional context needed._ - -## Environment - -- **Workspace:** Project root -- **Services required:** None - -## File Scope - -- `hello-taskplane.md` - -## Steps - -### Step 0: Preflight - -- [ ] Verify this PROMPT.md is readable -- [ ] Verify STATUS.md exists in the same folder - -### Step 1: Create Hello File - -- [ ] Create `hello-taskplane.md` in the project root -- [ ] Add a title plus lines containing today's date and task ID `EXAMPLE-001` - -### Step 2: Verification - -- [ ] Verify `hello-taskplane.md` exists and matches the expected content - -### Step 3: Delivery - - - -## Documentation Requirements - -**Must Update:** None -**Check If Affected:** None - -## Completion Criteria - -- [ ] `hello-taskplane.md` exists in the project root -- [ ] `hello-taskplane.md` includes a title, task ID (`EXAMPLE-001`), and current date - -## Git Commit Convention - -- **Implementation:** `feat(EXAMPLE-001): description` -- **Checkpoints:** `checkpoint: EXAMPLE-001 description` - -## Do NOT - -- Modify any existing project files -- Create files outside the project root -- Over-engineer this — it's a smoke test - ---- - -## Amendments (Added During Execution) - - diff --git a/taskplane-tasks/EXAMPLE-001-hello-world/STATUS.md b/taskplane-tasks/EXAMPLE-001-hello-world/STATUS.md deleted file mode 100644 index af27b02..0000000 --- a/taskplane-tasks/EXAMPLE-001-hello-world/STATUS.md +++ /dev/null @@ -1,73 +0,0 @@ -# EXAMPLE-001: Hello World — Status - -**Current Step:** Not Started -**Status:** 🔵 Ready for Execution -**Last Updated:** 2026-04-28 -**Review Level:** 0 -**Review Counter:** 0 -**Iteration:** 0 -**Size:** S - ---- - -### Step 0: Preflight -**Status:** ⬜ Not Started - -- [ ] Verify PROMPT.md is readable -- [ ] Verify STATUS.md exists - ---- - -### Step 1: Create Hello File -**Status:** ⬜ Not Started - -- [ ] Create `hello-taskplane.md` in project root -- [ ] Add title, date, and task ID (EXAMPLE-001) - ---- - -### Step 2: Verification -**Status:** ⬜ Not Started - -- [ ] Verify file exists and matches expected content - ---- - -### Step 3: Delivery -**Status:** ⬜ Not Started - - - ---- - -## Reviews - -| # | Type | Step | Verdict | File | -|---|------|------|---------|------| - ---- - -## Discoveries - -| Discovery | Disposition | Location | -|-----------|-------------|----------| - ---- - -## Execution Log - -| Timestamp | Action | Outcome | -|-----------|--------|---------| -| 2026-04-28 | Task staged | PROMPT.md and STATUS.md created | - ---- - -## Blockers - -*None* - ---- - -## Notes - -*This is an example task created by `taskplane init`. Delete it after verifying your setup works.* diff --git a/taskplane-tasks/EXAMPLE-002-parallel-smoke/PROMPT.md b/taskplane-tasks/EXAMPLE-002-parallel-smoke/PROMPT.md deleted file mode 100644 index 1a8137c..0000000 --- a/taskplane-tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +++ /dev/null @@ -1,97 +0,0 @@ -# Task: EXAMPLE-002 — Parallel Smoke - -**Created:** 2026-04-28 -**Size:** S - -## Review Level: 0 (None) - -**Assessment:** Trivial parallel-safe smoke task to demonstrate orchestrator lanes. -**Score:** 0/8 — Blast radius: 0, Pattern novelty: 0, Security: 0, Reversibility: 0 - -## Canonical Task Folder - -``` -taskplane-tasks/EXAMPLE-002-parallel-smoke/ -├── PROMPT.md ← This file (immutable above --- divider) -├── STATUS.md ← Execution state (worker updates this) -├── .reviews/ ← Reviewer output (task-runner creates this) -└── .DONE ← Created when complete -``` - -## Mission - -Create a simple `hello-taskplane-2.md` file in the project root. This task is -intentionally independent from EXAMPLE-001 so both can run in parallel when -using `/orch`. - -## Expected File Content - -`hello-taskplane-2.md` should include: - -- A title line (for example: `# Parallel Hello from Taskplane`) -- A line containing the task ID: `EXAMPLE-002` -- A short note that this task is parallel-safe - -## Dependencies - -- **None** - -## Context to Read First - -_No additional context needed._ - -## Environment - -- **Workspace:** Project root -- **Services required:** None - -## File Scope - -- `hello-taskplane-2.md` - -## Steps - -### Step 0: Preflight - -- [ ] Verify this PROMPT.md is readable -- [ ] Verify STATUS.md exists in the same folder - -### Step 1: Create Parallel Hello File - -- [ ] Create `hello-taskplane-2.md` in the project root -- [ ] Add title plus lines containing task ID `EXAMPLE-002` and a parallel-safe note - -### Step 2: Verification - -- [ ] Verify `hello-taskplane-2.md` exists and matches the expected content - -### Step 3: Delivery - - - -## Documentation Requirements - -**Must Update:** None -**Check If Affected:** None - -## Completion Criteria - -- [ ] `hello-taskplane-2.md` exists in the project root -- [ ] `hello-taskplane-2.md` includes a title, task ID (`EXAMPLE-002`), and a parallel-safe note - -## Git Commit Convention - -- **Implementation:** `feat(EXAMPLE-002): description` -- **Checkpoints:** `checkpoint: EXAMPLE-002 description` - -## Do NOT - -- Modify any existing project files -- Create files outside the project root -- Add dependencies between EXAMPLE-001 and EXAMPLE-002 - ---- - -## Amendments (Added During Execution) - - diff --git a/taskplane-tasks/EXAMPLE-002-parallel-smoke/STATUS.md b/taskplane-tasks/EXAMPLE-002-parallel-smoke/STATUS.md deleted file mode 100644 index 57a11ff..0000000 --- a/taskplane-tasks/EXAMPLE-002-parallel-smoke/STATUS.md +++ /dev/null @@ -1,73 +0,0 @@ -# EXAMPLE-002: Parallel Smoke — Status - -**Current Step:** Not Started -**Status:** 🔵 Ready for Execution -**Last Updated:** 2026-04-28 -**Review Level:** 0 -**Review Counter:** 0 -**Iteration:** 0 -**Size:** S - ---- - -### Step 0: Preflight -**Status:** ⬜ Not Started - -- [ ] Verify PROMPT.md is readable -- [ ] Verify STATUS.md exists - ---- - -### Step 1: Create Parallel Hello File -**Status:** ⬜ Not Started - -- [ ] Create `hello-taskplane-2.md` in project root -- [ ] Add title, task ID (EXAMPLE-002), and parallel-safe note - ---- - -### Step 2: Verification -**Status:** ⬜ Not Started - -- [ ] Verify file exists and matches expected content - ---- - -### Step 3: Delivery -**Status:** ⬜ Not Started - - - ---- - -## Reviews - -| # | Type | Step | Verdict | File | -|---|------|------|---------|------| - ---- - -## Discoveries - -| Discovery | Disposition | Location | -|-----------|-------------|----------| - ---- - -## Execution Log - -| Timestamp | Action | Outcome | -|-----------|--------|---------| -| 2026-04-28 | Task staged | PROMPT.md and STATUS.md created | - ---- - -## Blockers - -*None* - ---- - -## Notes - -*This is an example task created by `taskplane init` to demonstrate orchestrator-first onboarding.* From 4355e2e302bafeef0d764dc85eddc3d87f75b30d Mon Sep 17 00:00:00 2001 From: larock22 Date: Tue, 28 Apr 2026 16:49:22 -0500 Subject: [PATCH 36/44] rollback: before executing plan ideal-dependency-organization --- tests/architecture_test.rs | 183 ++++++++++++++++++++++++++++++++++++- 1 file changed, 181 insertions(+), 2 deletions(-) diff --git a/tests/architecture_test.rs b/tests/architecture_test.rs index aef065b..d5f1484 100644 --- a/tests/architecture_test.rs +++ b/tests/architecture_test.rs @@ -29,8 +29,13 @@ fn test_providers_do_not_import_cli_or_app() { } #[test] -fn test_app_does_not_import_cli() { - let forbidden = ["use crate::cli::"]; +fn test_app_depends_only_on_domain_contracts() { + let forbidden = [ + "use crate::cli::", + "use crate::bootstrap::", + "use crate::providers::", + "use crate::transport::", + ]; check_dir_for_forbidden_patterns("src/app", &forbidden); } @@ -54,6 +59,174 @@ fn test_render_text_only_called_from_cli() { } } +#[test] +fn test_t001_cli_request_module_import_contract() { + let cli_mod = read_repo_file("src/cli/mod.rs"); + assert!( + cli_mod.contains("pub mod request;"), + "T001 requires src/cli/mod.rs to expose the request module" + ); + + let request = read_repo_file("src/cli/request.rs"); + assert!( + request.contains("crate::cli::args") && request.contains("CliArgs"), + "T001 request module must import CliArgs from the CLI args boundary" + ); + assert!( + request.contains("crate::domain") && request.contains("SearchQuery"), + "T001 request module must import SearchQuery from the domain boundary" + ); + assert!( + request.contains("pub fn build_search_query"), + "T001 request module must expose build_search_query" + ); +} + +#[test] +fn test_t002_cli_runner_module_import_contract() { + let cli_mod = read_repo_file("src/cli/mod.rs"); + assert!( + cli_mod.contains("pub mod runner;"), + "T002 requires src/cli/mod.rs to expose the runner module" + ); + + let runner = read_repo_file("src/cli/runner.rs"); + let required_patterns = [ + ("use clap::Parser", "parse CLI args in run_from_env"), + ("CliArgs", "accept parsed CLI args"), + ("CliProvider", "branch on provider selection"), + ( + "build_search_query", + "delegate query normalization to cli::request", + ), + ( + "ProviderRegistry", + "request provider services from bootstrap", + ), + ( + "ProviderId", + "identify single-provider runs through bootstrap IDs", + ), + ( + "render_fanout_text", + "render fan-out output through the CLI output boundary", + ), + ( + "pub async fn run_from_env() -> i32", + "expose env-backed runner entrypoint", + ), + ( + "pub async fn run(args: CliArgs) -> i32", + "expose parsed-args runner entrypoint", + ), + ]; + + for (pattern, reason) in required_patterns { + assert!( + runner.contains(pattern), + "T002 runner import contract missing {pattern:?} to {reason}" + ); + } +} + +#[test] +fn test_t003_entrypoint_import_contract() { + let main = read_repo_file("src/main.rs"); + let forbidden_patterns = [ + "use clap::Parser", + "sophon_cli::bootstrap::", + "sophon_cli::domain::", + "sophon_cli::cli::args", + "sophon_cli::cli::output", + "mod single_provider_search", + "single_provider_search::", + ]; + + for pattern in forbidden_patterns { + assert!( + !main.contains(pattern), + "T003 requires src/main.rs to drop direct import/declaration {pattern:?}" + ); + } + + assert!( + main.contains("sophon_cli::cli::runner::run_from_env().await"), + "T003 requires src/main.rs to delegate runtime execution to cli::runner::run_from_env" + ); + assert!( + !Path::new("src/single_provider_search.rs").exists(), + "T003 requires src/single_provider_search.rs to be removed after its logic moves to cli::runner" + ); +} + +#[test] +fn test_t004_ideal_dependency_direction_import_contract() { + let main = read_repo_file("src/main.rs"); + let forbidden_entrypoint_patterns = [ + "sophon_cli::bootstrap::", + "sophon_cli::domain::", + "sophon_cli::cli::output", + "mod single_provider_search", + "single_provider_search::", + ]; + + for pattern in forbidden_entrypoint_patterns { + assert!( + !main.contains(pattern), + "T004 requires the entrypoint to delegate only to the CLI surface; found {pattern:?}" + ); + } + + assert!( + !Path::new("src/single_provider_search.rs").exists(), + "T004 requires the binary-private single_provider_search helper to be absent" + ); + + let forbidden_app_patterns = [ + "use crate::cli::", + "use crate::bootstrap::", + "use crate::providers::", + "use crate::transport::", + ]; + check_dir_for_forbidden_patterns("src/app", &forbidden_app_patterns); +} + +#[test] +fn test_t005_import_organization_docs_contract() { + let import_docs = read_repo_file("docs/import-organization.md"); + assert!( + import_docs.starts_with("---\n"), + "T005 requires docs/import-organization.md to have YAML frontmatter" + ); + + let required_guidance = [ + "main", + "CLI runner", + "CLI to bootstrap", + "bootstrap to app", + "app to domain only", + "providers to transport", + "transport to domain", + "domain to no outer layers", + ]; + for guidance in required_guidance { + assert!( + import_docs.contains(guidance), + "T005 import organization docs missing guidance phrase {guidance:?}" + ); + } + + let current_map = read_repo_file("docs/dependency-architecture-map.html"); + assert!( + current_map.contains("cli::runner"), + "T005 current dependency map should show cli::runner after the refactor" + ); + assert!( + !current_map.contains("single_provider_search"), + "T005 current dependency map should no longer mention single_provider_search" + ); +} + fn check_dir_for_forbidden_patterns(dir: &str, forbidden: &[&str]) { visit_rust_files(dir, &|path, content| { for pat in forbidden { @@ -67,6 +240,12 @@ fn check_dir_for_forbidden_patterns(dir: &str, forbidden: &[&str]) { }); } +fn read_repo_file(path: &str) -> String { + fs::read_to_string(path).unwrap_or_else(|error| { + panic!("Expected {path} to exist for import-organization contract test: {error}") + }) +} + fn visit_rust_files(dir: &str, callback: &dyn Fn(&Path, &str)) { let path = Path::new(dir); if !path.exists() { From 39e65d25c481ca01a5aec42e9c1a23dc410e4989 Mon Sep 17 00:00:00 2001 From: larock22 Date: Tue, 28 Apr 2026 16:50:07 -0500 Subject: [PATCH 37/44] T001: Add CLI request normalization module Add cli::request::build_search_query with focused field mapping coverage and expose it from the CLI module. Refs: plan/2026-04-28_16-23-25_ideal-dependency-organization/PLAN.md --- src/cli/mod.rs | 1 + src/cli/request.rs | 50 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 src/cli/request.rs diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 3f2a241..6748dde 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,2 +1,3 @@ pub mod args; pub mod output; +pub mod request; diff --git a/src/cli/request.rs b/src/cli/request.rs new file mode 100644 index 0000000..496ac3f --- /dev/null +++ b/src/cli/request.rs @@ -0,0 +1,50 @@ +use crate::cli::args::CliArgs; +use crate::domain::SearchQuery; + +pub fn build_search_query(query_text: String, args: &CliArgs) -> SearchQuery { + SearchQuery { + text: query_text, + search_type: args.search_type.into(), + limit: args.limit, + offset: args.offset, + safe_search: args.safe_search.map(|safe_search| safe_search.into()), + country: args.country.clone(), + language: args.language.clone(), + time_range: None, + } +} + +#[cfg(test)] +mod tests { + use crate::cli::args::{CliArgs, CliProvider, CliSafeSearch, CliSearchType}; + use crate::cli::request::build_search_query; + use crate::domain::{SafeSearch, SearchType}; + + #[test] + fn build_search_query_maps_all_supported_fields() { + let args = CliArgs { + query: Some("ignored parsed query".to_string()), + about: false, + search_type: CliSearchType::News, + provider: CliProvider::All, + limit: Some(7), + offset: Some(14), + safe_search: Some(CliSafeSearch::Strict), + country: Some("US".to_string()), + language: Some("en".to_string()), + }; + + let query = build_search_query("rust search".to_string(), &args); + + assert_eq!(query.text, "rust search"); + assert_eq!(query.search_type, SearchType::News); + assert_eq!(query.limit, Some(7)); + assert_eq!(query.offset, Some(14)); + assert_eq!(query.safe_search, Some(SafeSearch::Strict)); + assert_eq!(query.country, Some("US".to_string())); + assert_eq!(query.language, Some("en".to_string())); + assert_eq!(query.time_range, None); + assert_eq!(args.country, Some("US".to_string())); + assert_eq!(args.language, Some("en".to_string())); + } +} From cbc4e7535134a99f114be05a8315dd6cadf02990 Mon Sep 17 00:00:00 2001 From: larock22 Date: Tue, 28 Apr 2026 16:51:38 -0500 Subject: [PATCH 38/44] T002: Add CLI runner boundary Move CLI runtime orchestration into cli::runner with parsed-args and environment entrypoints, provider selection, rendering, and return-code based errors. Refs: plan/2026-04-28_16-23-25_ideal-dependency-organization/PLAN.md --- src/cli/mod.rs | 1 + src/cli/runner.rs | 98 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 src/cli/runner.rs diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 6748dde..87a3603 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,3 +1,4 @@ pub mod args; pub mod output; pub mod request; +pub mod runner; diff --git a/src/cli/runner.rs b/src/cli/runner.rs new file mode 100644 index 0000000..9e9e8b1 --- /dev/null +++ b/src/cli/runner.rs @@ -0,0 +1,98 @@ +use clap::Parser; + +use crate::bootstrap::provider_registry::{ProviderId, ProviderRegistry}; +use crate::cli::args::{CliArgs, CliProvider}; +use crate::cli::output::{render_fanout_text, render_text}; +use crate::cli::request::build_search_query; +use crate::domain::SearchQuery; + +pub async fn run_from_env() -> i32 { + let args = CliArgs::parse(); + run(args).await +} + +pub async fn run(args: CliArgs) -> i32 { + if args.about { + print_about(); + return 0; + } + + let query_text = match args.query.clone() { + Some(query) => query, + None => { + eprintln!("Error: missing query. Use --help for usage or --about for more info."); + return 1; + } + }; + + let query = build_search_query(query_text, &args); + let registry = ProviderRegistry::production_from_env(); + + match args.provider { + CliProvider::Brave => run_single_provider(®istry, ProviderId::Brave, query).await, + CliProvider::Exa => run_single_provider(®istry, ProviderId::Exa, query).await, + CliProvider::All => run_all_enabled(®istry, query).await, + } +} + +fn print_about() { + println!("sophon-cli — a provider-agnostic search CLI"); + println!(); + println!("Named after the Sophon from Cixin Liu's Three-Body Problem trilogy:"); + println!("a sentient proton supercomputer that performs near-infinite computation"); + println!("across vast distances. This tiny CLI delegates its heavy lifting to"); + println!("distant search APIs the same way."); + println!(); + println!("Currently supports Brave Search (web, news, images, video) and Exa."); +} + +async fn run_single_provider( + registry: &ProviderRegistry, + provider_id: ProviderId, + query: SearchQuery, +) -> i32 { + tracing::info!(provider = %provider_id, query = %query.text, "initializing search service"); + + let service = match registry.build(provider_id) { + Ok(service) => service, + Err(error) => { + tracing::error!(%error, "failed to build provider"); + eprintln!("{error}"); + return 1; + } + }; + + match service.search(query).await { + Ok(response) => { + tracing::info!(result_count = response.results.len(), total_estimated = ?response.total_estimated, "search completed"); + println!("{}", render_text(&response)); + 0 + } + Err(error) => { + tracing::error!(error = %error, "search failed"); + eprintln!("Search failed: {}", error); + 1 + } + } +} + +async fn run_all_enabled(registry: &ProviderRegistry, query: SearchQuery) -> i32 { + tracing::info!(query = %query.text, "initializing all-enabled provider fan-out service"); + let service = match registry.build_all_enabled() { + Ok(service) => service, + Err(error) => { + tracing::error!(%error, "failed to build fan-out providers"); + eprintln!("{error}"); + return 1; + } + }; + + let response = service.search_all(query).await; + tracing::info!( + successful_providers = response.responses.len(), + failed_providers = response.failures.len(), + "fan-out search completed" + ); + println!("{}", render_fanout_text(&response)); + if response.responses.is_empty() { 1 } else { 0 } +} From f8401ea776583c0c12cf4886c67e20bad311f808 Mon Sep 17 00:00:00 2001 From: larock22 Date: Tue, 28 Apr 2026 16:52:01 -0500 Subject: [PATCH 39/44] T003: Thin binary entrypoint Delegate process execution to cli::runner::run_from_env and remove the binary-private single provider helper. Refs: plan/2026-04-28_16-23-25_ideal-dependency-organization/PLAN.md --- src/main.rs | 69 ++--------------------------------- src/single_provider_search.rs | 29 --------------- 2 files changed, 3 insertions(+), 95 deletions(-) delete mode 100644 src/single_provider_search.rs diff --git a/src/main.rs b/src/main.rs index 9a1f99b..b96dd0b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,5 @@ -use clap::Parser; -use sophon_cli::bootstrap::provider_registry::{ProviderId, ProviderRegistry}; -use sophon_cli::cli::args::{CliArgs, CliProvider}; -use sophon_cli::cli::output::render_fanout_text; -use sophon_cli::domain::SearchQuery; use tracing_subscriber::EnvFilter; -mod single_provider_search; - -use single_provider_search::run_single_provider; - #[tokio::main] async fn main() { tracing_subscriber::fmt() @@ -18,62 +9,8 @@ async fn main() { dotenvy::dotenv().ok(); - let args = CliArgs::parse(); - - if args.about { - println!("sophon-cli — a provider-agnostic search CLI"); - println!(); - println!("Named after the Sophon from Cixin Liu's Three-Body Problem trilogy:"); - println!("a sentient proton supercomputer that performs near-infinite computation"); - println!("across vast distances. This tiny CLI delegates its heavy lifting to"); - println!("distant search APIs the same way."); - println!(); - println!("Currently supports Brave Search (web, news, images, video) and Exa."); - return; - } - - let query_text = match args.query { - Some(q) => q, - None => { - eprintln!("Error: missing query. Use --help for usage or --about for more info."); - std::process::exit(1); - } - }; - - let query = SearchQuery { - text: query_text, - search_type: args.search_type.into(), - limit: args.limit, - offset: args.offset, - safe_search: args.safe_search.map(|s| s.into()), - country: args.country, - language: args.language, - time_range: None, - }; - - let registry = ProviderRegistry::production_from_env(); - - match args.provider { - CliProvider::Brave => run_single_provider(®istry, ProviderId::Brave, query).await, - CliProvider::Exa => run_single_provider(®istry, ProviderId::Exa, query).await, - CliProvider::All => { - tracing::info!(query = %query.text, "initializing all-enabled provider fan-out service"); - let service = registry.build_all_enabled().unwrap_or_else(|error| { - tracing::error!(%error, "failed to build fan-out providers"); - eprintln!("{error}"); - std::process::exit(1); - }); - - let response = service.search_all(query).await; - tracing::info!( - successful_providers = response.responses.len(), - failed_providers = response.failures.len(), - "fan-out search completed" - ); - println!("{}", render_fanout_text(&response)); - if response.responses.is_empty() { - std::process::exit(1); - } - } + let exit_code = sophon_cli::cli::runner::run_from_env().await; + if exit_code != 0 { + std::process::exit(exit_code); } } diff --git a/src/single_provider_search.rs b/src/single_provider_search.rs deleted file mode 100644 index 53892eb..0000000 --- a/src/single_provider_search.rs +++ /dev/null @@ -1,29 +0,0 @@ -use sophon_cli::bootstrap::provider_registry::{ProviderId, ProviderRegistry}; -use sophon_cli::cli::output::render_text; -use sophon_cli::domain::SearchQuery; - -pub async fn run_single_provider( - registry: &ProviderRegistry, - provider_id: ProviderId, - query: SearchQuery, -) { - tracing::info!(provider = %provider_id, query = %query.text, "initializing search service"); - - let service = registry.build(provider_id).unwrap_or_else(|error| { - tracing::error!(%error, "failed to build provider"); - eprintln!("{error}"); - std::process::exit(1); - }); - - match service.search(query).await { - Ok(response) => { - tracing::info!(result_count = response.results.len(), total_estimated = ?response.total_estimated, "search completed"); - println!("{}", render_text(&response)); - } - Err(e) => { - tracing::error!(error = %e, "search failed"); - eprintln!("Search failed: {}", e); - std::process::exit(1); - } - } -} From 00f5930417564ef44e3c3e1ff2c4ad77afaffb31 Mon Sep 17 00:00:00 2001 From: larock22 Date: Tue, 28 Apr 2026 16:52:35 -0500 Subject: [PATCH 40/44] T004: Strengthen architecture boundaries Add the explicit entrypoint-to-CLI boundary test and reuse it in the ideal dependency direction contract. Refs: plan/2026-04-28_16-23-25_ideal-dependency-organization/PLAN.md --- tests/architecture_test.rs | 49 ++++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/tests/architecture_test.rs b/tests/architecture_test.rs index d5f1484..823e35d 100644 --- a/tests/architecture_test.rs +++ b/tests/architecture_test.rs @@ -160,27 +160,13 @@ fn test_t003_entrypoint_import_contract() { } #[test] -fn test_t004_ideal_dependency_direction_import_contract() { - let main = read_repo_file("src/main.rs"); - let forbidden_entrypoint_patterns = [ - "sophon_cli::bootstrap::", - "sophon_cli::domain::", - "sophon_cli::cli::output", - "mod single_provider_search", - "single_provider_search::", - ]; - - for pattern in forbidden_entrypoint_patterns { - assert!( - !main.contains(pattern), - "T004 requires the entrypoint to delegate only to the CLI surface; found {pattern:?}" - ); - } +fn test_entrypoint_delegates_only_to_cli_surface() { + assert_entrypoint_delegates_only_to_cli_surface(); +} - assert!( - !Path::new("src/single_provider_search.rs").exists(), - "T004 requires the binary-private single_provider_search helper to be absent" - ); +#[test] +fn test_t004_ideal_dependency_direction_import_contract() { + assert_entrypoint_delegates_only_to_cli_surface(); let forbidden_app_patterns = [ "use crate::cli::", @@ -227,6 +213,29 @@ fn test_t005_import_organization_docs_contract() { ); } +fn assert_entrypoint_delegates_only_to_cli_surface() { + let main = read_repo_file("src/main.rs"); + let forbidden_entrypoint_patterns = [ + "sophon_cli::bootstrap::", + "sophon_cli::domain::", + "sophon_cli::cli::output", + "mod single_provider_search", + "single_provider_search::", + ]; + + for pattern in forbidden_entrypoint_patterns { + assert!( + !main.contains(pattern), + "T004 requires the entrypoint to delegate only to the CLI surface; found {pattern:?}" + ); + } + + assert!( + !Path::new("src/single_provider_search.rs").exists(), + "T004 requires the binary-private single_provider_search helper to be absent" + ); +} + fn check_dir_for_forbidden_patterns(dir: &str, forbidden: &[&str]) { visit_rust_files(dir, &|path, content| { for pat in forbidden { From 7e6784646b97acb4375415de55a7bfd97fb02622 Mon Sep 17 00:00:00 2001 From: larock22 Date: Tue, 28 Apr 2026 16:53:49 -0500 Subject: [PATCH 41/44] T005: Update dependency organization docs Refresh architecture docs and the current dependency map for the CLI runner boundary, and add import organization guidance. Refs: plan/2026-04-28_16-23-25_ideal-dependency-organization/PLAN.md --- docs/architecture.md | 29 +++++++++++--------- docs/dependency-architecture-map.html | 13 ++++----- docs/dependency-architecture-map.md | 2 +- docs/import-organization.md | 39 +++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 19 deletions(-) create mode 100644 docs/import-organization.md diff --git a/docs/architecture.md b/docs/architecture.md index 6b393b4..b8f5aab 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -37,36 +37,41 @@ bootstrap composes app + providers + transport at startup └── user runs: cargo run -- "query" --search-type news --limit 3 2. src/main.rs + └── initializes tracing and dotenv + └── delegates runtime execution to src/cli/runner.rs + +3. src/cli/runner.rs └── CliArgs::parse() produces CliArgs { query, provider, search_type, limit, ... } - └── maps CliArgs → SearchQuery - └── for `brave` or `exa`, asks ProviderRegistry::build(provider) for SearchService - └── for `all`, asks ProviderRegistry::build_all_enabled() for FanoutSearchService + └── src/cli/request.rs maps CliArgs + query text → SearchQuery + └── selects single-provider or all-provider mode + └── requests services from ProviderRegistry + └── renders stdout through src/cli/output.rs -3. src/app/search_service.rs or src/app/fanout_search_service.rs +4. src/app/search_service.rs or src/app/fanout_search_service.rs └── SearchService::search(SearchQuery) awaits one provider └── FanoutSearchService::search_all(SearchQuery) awaits enabled providers sequentially └── delegates to dyn SearchProvider trait objects -4. src/providers/*/client.rs +5. src/providers/*/client.rs └── provider-specific SearchProvider::search(&SearchQuery) └── Brave builds GET endpoint + query params └── Exa builds POST /search JSON body └── calls HttpClient::get_json() or HttpClient::post_json() -5. src/transport/http.rs +6. src/transport/http.rs └── ReqwestHttpClient executes HTTP GET └── on success: deserializes JSON into BraveNewsResponse └── on failure: maps status code → SearchError -6. src/providers/brave/mapper.rs +7. src/providers/brave/mapper.rs └── map_news_response(BraveNewsResponse) → SearchResponse └── transforms DTOs into domain SearchResult::News items -7. src/cli/output.rs +8. src/cli/output.rs └── render_text(&SearchResponse) → String for single-provider output └── render_fanout_text(&SearchBatchResponse) → String for all-provider output -8. src/main.rs +9. src/cli/runner.rs └── println!("{}", rendered_string) ``` @@ -168,7 +173,7 @@ Errors are created at the layer where the failure occurs and bubble upward uncha 1. **Transport layer** — `reqwest` failures, non-2xx HTTP status, or JSON decode errors become `SearchError::Transport`, `SearchError::Provider`, or `SearchError::Decode`. 2. **Provider layer** — can surface `SearchError` directly; does not wrap in another error type. 3. **App layer** — `SearchService` returns the `SearchError` untouched; `FanoutSearchService` records per-provider failures in `SearchBatchResponse` and continues to later providers. -4. **CLI layer** — `main.rs` matches on single-provider `SearchError` and prints a human-readable message to `stderr`, or renders fan-out successes and failures and exits with code `1` when no provider succeeded. +4. **CLI layer** — `src/cli/runner.rs` matches on single-provider `SearchError` and prints a human-readable message to `stderr`, or renders fan-out successes and failures and returns exit code `1` when no provider succeeded. This keeps error handling simple: there is only one error type in the public API. @@ -196,7 +201,7 @@ Unsupported Exa inputs are rejected at runtime instead of being ignored: `Images ## Runtime provider selection -`main.rs` remains the binary edge that chooses either a single-provider path or the all-enabled-provider fan-out path. Concrete provider construction lives in `src/bootstrap/provider_registry.rs`, where typed provider config, HTTP transport, provider clients, `SearchService`, and `FanoutSearchService` are composed. +`src/main.rs` remains a thin process entrypoint. It initializes process-level concerns and delegates to `src/cli/runner.rs`, which owns user-surface branching, query normalization, output rendering, and exit-code calculation. Concrete provider construction remains in `src/bootstrap/provider_registry.rs`, where typed provider config, HTTP transport, provider clients, `SearchService`, and `FanoutSearchService` are composed. - `--provider brave` uses `ProviderRegistry::build(ProviderId::Brave)`; the registry includes it only when `BRAVE_API_KEY` is configured - `--provider exa` uses `ProviderRegistry::build(ProviderId::Exa)`; the registry includes it only when `EXA_API_KEY` is configured @@ -214,7 +219,7 @@ The rules are verified by `tests/architecture_test.rs`. These tests scan source | `src/domain/` | `crate::providers::`, `crate::transport::`, `crate::cli::`, `crate::app::` | | `src/transport/` | `crate::providers::`, `crate::cli::`, `crate::app::` | | `src/providers/` | `crate::cli::`, `crate::app::` | -| `src/app/` | `crate::cli::` | +| `src/app/` | `crate::cli::`, `crate::bootstrap::`, `crate::providers::`, `crate::transport::` | | `src/bootstrap/` | `crate::cli::` | | Any layer except `src/cli/` | `render_text` | diff --git a/docs/dependency-architecture-map.html b/docs/dependency-architecture-map.html index 3db3b49..d585e26 100644 --- a/docs/dependency-architecture-map.html +++ b/docs/dependency-architecture-map.html @@ -16,7 +16,7 @@
current node-and-arrow map

Current dependency architecture map.

-

This is the current codebase as a real map: boxes are actual files/modules, arrows are source-code import dependencies. It is intentionally less abstract than the ideal map so you can compare what exists to the target shape.

+

This is the current codebase as a real map after the runtime organization refactor: the entrypoint delegates to the CLI runner, the CLI surface owns user-facing branching and rendering, bootstrap owns concrete construction, and domain remains the bottom layer.

@@ -27,9 +27,10 @@

Current dependency architecture map.

Current dependency architecture map.

- main imports CLI + bootstrap + domain + entrypoint delegates to CLI runner bootstrap wires concrete providers + transport providers map DTOs into domain domain sibling imports only @@ -57,7 +58,7 @@

Current dependency architecture map.

src/main.rsentrypoint
cli::argsparses flags into domain options
cli::outputrenders domain responses
-
single_provider_searchhelper using registry + renderer
+
cli::runnerprovider mode + rendering flow
bootstrap::provider_registrybuilds services/providers/transport
app::SearchServicesingle provider orchestration
app::FanoutSearchServicemulti-provider orchestration
@@ -75,7 +76,7 @@

Current dependency architecture map.

How to read this map

-
Green arrows

Main architectural flow: entry → CLI/bootstrap → app → adapters → domain.

+
Green arrows

Main architectural flow: entry → CLI surface → bootstrap/app → adapters → domain.

Blue arrows

Supporting imports, mostly direct imports into the domain facade/types.

Dashed arrows

Local sibling dependencies, like provider client → mapper and domain file → domain sibling.

diff --git a/docs/dependency-architecture-map.md b/docs/dependency-architecture-map.md index 1bda19e..d27d03b 100644 --- a/docs/dependency-architecture-map.md +++ b/docs/dependency-architecture-map.md @@ -18,7 +18,7 @@ ontology_relations: # Current Dependency Architecture Map -This is the current/actual Rust import shape, drawn as a clean architecture map. +This is the current/actual Rust import shape after the runtime organization refactor: `src/main.rs` delegates to the CLI runner, the CLI surface performs user-facing branching and rendering, bootstrap owns concrete construction, app services orchestrate domain provider traits, and domain remains the bottom layer. Read every dependency as: diff --git a/docs/import-organization.md b/docs/import-organization.md new file mode 100644 index 0000000..245da7d --- /dev/null +++ b/docs/import-organization.md @@ -0,0 +1,39 @@ +--- +title: "Import Organization" +when_to_read: + - "When changing Rust module boundaries or adding imports across runtime layers." + - "When checking whether a new module belongs in CLI, bootstrap, app, providers, transport, or domain." +summary: "Maintainer guidance for the allowed import direction in sophon-cli after the CLI runner and request-normalization refactor." +ontology_relations: + - relation: "explains" + target: "docs/architecture.md" + note: "Documents the import rules behind the architecture page." + - relation: "explains" + target: "docs/dependency-architecture-map.md" + note: "Describes the allowed edges shown by the current dependency map." +--- + +# Import Organization + +Keep imports flowing from user-facing edges toward stable domain contracts. Do not move concrete provider construction into the application layer, and do not let provider-specific details leak into domain types. + +## Allowed imports by layer + +| Layer | Allowed direction | +|-------|-------------------| +| `src/main.rs` | `main` to CLI runner only for runtime delegation, plus process setup dependencies such as tracing and dotenv | +| `src/cli/` | CLI to bootstrap, domain, args, request, and output helpers; the CLI runner owns provider-mode branching and rendering | +| `src/bootstrap/` | bootstrap to app, providers, transport, and domain; this is the composition root for concrete services | +| `src/app/` | app to domain only; services orchestrate `SearchProvider` trait objects | +| `src/providers/` | providers to transport and domain only, plus provider-local DTO, mapper, and config modules | +| `src/transport/` | transport to domain only for shared `SearchError` contracts | +| `src/domain/` | domain to no outer layers | + +## Review checklist + +- `src/main.rs` should continue delegating to `sophon_cli::cli::runner::run_from_env().await`. +- `src/cli/request.rs` should remain the place where `CliArgs` plus query text become `SearchQuery`. +- `src/cli/runner.rs` may request services from `ProviderRegistry`, but concrete provider construction must stay in `src/bootstrap/provider_registry.rs`. +- `src/app/` modules should accept domain types and domain traits only. +- `src/providers/` modules should map provider DTOs into domain responses before crossing back upward. +- `src/transport/` should return domain errors rather than provider-specific error types. From a21326aa0db0d63e93b1b714e24023b113b2e605 Mon Sep 17 00:00:00 2001 From: larock22 Date: Tue, 28 Apr 2026 17:00:40 -0500 Subject: [PATCH 42/44] docs: consolidate architecture docs into current dependency map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove stale architecture documentation and update the canonical dependency-architecture-map.html to reflect the post-refactor module structure. Deleted docs (content was outdated or duplicated by the map itself): - docs/dependency-direction.html + .md — standalone dependency visual - docs/ideal-dependency-architecture-map.html + .md — ideal target map - docs/import-organization.md — import rules now encoded in tests Updated docs/dependency-architecture-map.html: - Rewrote SVG to show cli::runner, cli::request, and cli::output - Added bootstrap::provider_registry as the composition root - Added app::FanoutSearchService alongside app::SearchService - Added exa::client/config/dto and exa::mapper adapter nodes - Updated arrow annotations and accuracy footer for post-refactor state Updated tests/architecture_test.rs: - Renamed test_t005_import_organization_docs_contract to test_t005_current_dependency_map_reflects_refactor - Removed import-organization.md existence and content checks - Kept dependency-architecture-map.html assertions (cli::runner present, single_provider_search absent) Validation: just check passes (fmt, clippy, 59 tests, mdBook build, frontmatter check) --- docs/dependency-architecture-map.html | 428 +++++++++++++++--- docs/dependency-direction.html | 469 -------------------- docs/dependency-direction.md | 61 --- docs/ideal-dependency-architecture-map.html | 70 --- docs/ideal-dependency-architecture-map.md | 59 --- docs/import-organization.md | 39 -- tests/architecture_test.rs | 25 +- 7 files changed, 357 insertions(+), 794 deletions(-) delete mode 100644 docs/dependency-direction.html delete mode 100644 docs/dependency-direction.md delete mode 100644 docs/ideal-dependency-architecture-map.html delete mode 100644 docs/ideal-dependency-architecture-map.md delete mode 100644 docs/import-organization.md diff --git a/docs/dependency-architecture-map.html b/docs/dependency-architecture-map.html index d585e26..1fe888d 100644 --- a/docs/dependency-architecture-map.html +++ b/docs/dependency-architecture-map.html @@ -3,83 +3,367 @@ -sophon-cli Current Dependency Map +sophon-cli Current Dependency Architecture Map - + -
-
-
current node-and-arrow map
-

Current dependency architecture map.

-

This is the current codebase as a real map after the runtime organization refactor: the entrypoint delegates to the CLI runner, the CLI surface owns user-facing branching and rendering, bootstrap owns concrete construction, and domain remains the bottom layer.

- -
- -
-
Current mapactual files/modules, routed by architecture layer
-
-
- - -
src/main.rsentrypoint
-
cli::argsparses flags into domain options
-
cli::outputrenders domain responses
-
cli::runnerprovider mode + rendering flow
-
bootstrap::provider_registrybuilds services/providers/transport
-
app::SearchServicesingle provider orchestration
-
app::FanoutSearchServicemulti-provider orchestration
-
brave::clientdomain + HttpClient + local modules
-
brave::mapperDTOs → domain results
-
exa::clientdomain + HttpClient + local modules
-
exa::mapperDTOs → domain results
-
transport::httpHTTP + domain SearchError
-
domain::errorSearchError
-
domain::querySearchQuery
-
domain::providerSearchProvider trait
-
domain::resultSearchResponse/results
-
domain::typesSearchType/SafeSearch
-
-
- -

How to read this map

-
Green arrows

Main architectural flow: entry → CLI surface → bootstrap/app → adapters → domain.

-
Blue arrows

Supporting imports, mostly direct imports into the domain facade/types.

-
Dashed arrows

Local sibling dependencies, like provider client → mapper and domain file → domain sibling.

-
-
+
+
+
current import map
+

sophon-cli dependency architecture

+

Accurate source dependency map after the CLI runner refactor. Arrows mean importer depends on target. The entrypoint delegates to the CLI surface; bootstrap owns concrete provider and HTTP wiring; app services orchestrate domain trait objects; providers and transport depend downward on domain contracts.

+
+ +
+ + sophon-cli current dependency architecture map + Layered Rust module import map from entrypoint to CLI, bootstrap, app services, provider adapters, transport, and domain core. + + + + + + + + + + + + + + + + + + + + + + + + + + entry + + cli user surface + + bootstrap composition root + + application services + + adapters + + domain core + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + only runtime import from main + runner asks registry for services + bootstrap owns concrete construction + app sees SearchProvider trait objects only + all adapters return domain contracts + + + + + + src/main.rs + tracing + dotenv + run_from_env + + + + cli::runner + about, missing query + provider mode, exit code + + + + cli::args + clap flags + enums + + + + cli::request + CliArgs to SearchQuery + + + + cli::output + render_text + fanout + + + + bootstrap::provider_registry + ProviderRegistry + ProviderId + builds services and adapters + + + + app::SearchService + single SearchProvider orchestration + + + + app::FanoutSearchService + sequential provider fan-out + + + + brave::client/config/dto + SearchProvider + GET API DTOs + + + + brave::mapper + DTOs to SearchResponse + + + + exa::client/config/dto + SearchProvider + POST DTOs + + + + exa::mapper + DTOs to SearchResponse + + + + transport::http + HttpClient + ReqwestHttpClient + + + + domain::query/types + SearchQuery, SearchType + + + + domain::provider + SearchProvider trait + + + + domain::result/error + responses, failures, errors + + + + + + Accuracy notes + Deleted binary-private helper is intentionally absent. main imports only cli::runner at runtime. app imports domain only. + bootstrap imports app, providers, transport, and domain. providers import transport and domain. transport imports domain::SearchError. + + +
+ +
+
+

CLI surface

+
    +
  • * `cli::runner` owns user-facing branching and rendering.
  • +
  • * `cli::request` is the CLI-to-domain query boundary.
  • +
  • * `main` delegates to `run_from_env()` and handles exit.
  • +
+
+
+

Composition

+
    +
  • * `ProviderRegistry` is the concrete composition root.
  • +
  • * It builds app services from Brave/Exa and reqwest transport.
  • +
  • * The CLI asks for services; it does not construct providers.
  • +
+
+
+

Domain direction

+
    +
  • * App services import only domain contracts.
  • +
  • * Providers implement domain traits and use transport.
  • +
  • * Domain has no upward imports.
  • +
+
+
+ +
Generated for docs/dependency-architecture-map.html. Compare with docs/ideal-dependency-architecture-map.html.
+
diff --git a/docs/dependency-direction.html b/docs/dependency-direction.html deleted file mode 100644 index 3175c2a..0000000 --- a/docs/dependency-direction.html +++ /dev/null @@ -1,469 +0,0 @@ - - - - - -sophon-cli Dependency Direction - - - - - - -
-
-
sophon-cli dependency direction
-

Small maps beat giant hairballs.

-

- cargo visualize --all-deps shows every transitive dependency. That is useful for audits, but noisy for orientation. - This page keeps only the two maps maintainers usually need: direct external crates and internal module direction. -

- -
- -
-
-
-
- -

Direct Cargo dependencies

-
-

- Read this as: sophon-cli depends on each crate below. Transitive crates are intentionally hidden. -

-
- -
- sophon-cli - package root from Cargo.toml -
-
direct dependency direction
- -
-
-

Runtime + HTTP

-
    -
  • tokioasync runtime
  • -
  • reqwestHTTP client
  • -
-
- -
-

Data encoding

-
    -
  • serdederive + DTOs
  • -
  • serde_jsonJSON body/value
  • -
  • chronotimestamps
  • -
-
- -
-

CLI interface

-
    -
  • clapargument parser
  • -
  • dotenvy.env loading
  • -
-
- -
-

Errors + traits

-
    -
  • thiserrorerror enums
  • -
  • async-traitasync provider trait
  • -
-
- -
-

Observability

-
    -
  • tracingstructured spans
  • -
  • tracing-subscriberstderr logging
  • -
-
- -
-

Dev-only

-
    -
  • cargo-huskylocal hooks
  • -
-
-
- -
- runtime/http - data - CLI - support - dev-only -
-
- -
-
-
- -

Internal layer direction

-
-

- Read this top-to-bottom: upper code may depend on lower code. Lower layers should not import upward. -

-
- -
-
-
Entry + CLIoutermost user surface
-
-
src/main.rssrc/cli/args.rssrc/cli/output.rssrc/single_provider_search.rs
-

Parses CLI flags, maps them into domain queries, and prints rendered output.

-
-
-
depends on ↓
- -
-
Bootstrapcomposition root
-
-
src/bootstrap/provider_registry.rs
-

Reads provider configuration and wires provider adapters to the HTTP transport.

-
-
-
depends on ↓
- -
-
Applicationorchestration only
-
-
src/app/search_service.rssrc/app/fanout_search_service.rs
-

Coordinates one provider or all enabled providers through domain traits.

-
-
-
depends on ↓
- -
-
Adaptersprovider + transport edge
-
-
src/providers/brave/*src/providers/exa/*src/transport/http.rs
-

Turns provider-specific HTTP/JSON details into provider-agnostic domain results.

-
-
-
depends on ↓
- -
-
Domainbottom, pure contracts
-
-
src/domain/query.rssrc/domain/result.rssrc/domain/provider.rssrc/domain/error.rs
-

Provider-agnostic types, errors, and traits. This layer must not know about CLI, providers, app, or transport.

-
-
-
- -
-
Allowed

providers can import domain to implement SearchProvider.

-
Forbidden

domain should not import providers, transport, app, or cli.

-
Enforced by

tests/architecture_test.rs scans source files for boundary leaks.

-
-
- -
-
-
- -

Use smaller graph commands

-
-

These commands keep the visualization oriented around the question you are asking.

-
-
-
cargo tree --depth 1Terminal-only view of direct dependencies.
-
cargo visualize --depth 1 --dedup-transitive-depsSmall browser graph: direct dependencies only.
-
cargo visualize --focus reqwest --depth 2 --dedup-transitive-depsFocused browser graph: why one crate is present and what it pulls nearby.
-
cargo tree --invert tokioReverse question: who depends on this crate?
-
-
-
-
- - diff --git a/docs/dependency-direction.md b/docs/dependency-direction.md deleted file mode 100644 index 4779691..0000000 --- a/docs/dependency-direction.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -title: "Dependency Direction Visual" -when_to_read: - - "When you want a small visual map of sophon-cli dependency direction without the full transitive Cargo graph." - - "When checking direct crate dependencies or internal layer dependency direction." -summary: "Embedded visual explainer for sophon-cli dependency direction: grouped direct Cargo dependencies and top-to-bottom internal module direction." -ontology_relations: - - relation: "part_of" - target: "docs/SUMMARY.md" - note: "Belongs to the mdBook documentation set." - - relation: "explains" - target: "docs/architecture.md" - note: "Visualizes the architecture dependency direction described by the architecture page." ---- - -# Dependency Direction Visual - -This page is a small, organized alternative to the full `cargo visualize --all-deps` graph. - -Use it for two questions: - -1. **Which crates does `sophon-cli` directly depend on?** -2. **Which direction should internal modules depend on?** - -Open the standalone visual page: - -[Dependency direction visual](dependency-direction.html) - - - -If the embedded frame is cramped, open the standalone page above. - -## Related commands - -Direct dependencies only: - -```bash -cargo tree --depth 1 -``` - -Small browser graph: - -```bash -cargo visualize --depth 1 --dedup-transitive-deps -``` - -Focused browser graph for one crate: - -```bash -cargo visualize --focus reqwest --depth 2 --dedup-transitive-deps -``` - -Reverse dependency question: - -```bash -cargo tree --invert tokio -``` diff --git a/docs/ideal-dependency-architecture-map.html b/docs/ideal-dependency-architecture-map.html deleted file mode 100644 index 2d10615..0000000 --- a/docs/ideal-dependency-architecture-map.html +++ /dev/null @@ -1,70 +0,0 @@ - - - - - -sophon-cli Ideal Dependency Map - - - - - - -
-
-
ideal node-and-arrow map
-

Ideal dependency architecture map.

-

This is the target shape as an actual map: boxes are architecture responsibilities, arrows are allowed source-code dependency direction. Everything routes downward into the domain core.

- -
- -
-
Ideal mapclean target: composition owns concretes, app owns orchestration, domain owns contracts
-
-
- - -
Entrypointstart program, delegate, handle exit
-
CLI Surfaceparse input + render output; domain vocabulary only
-
Bootstrapsingle composition root for provider + transport wiring
-
Application Servicesorchestrate SearchProvider trait objects
-
Provider AdaptersBrave / Exa clients, DTOs, mappers, configs
-
Transport AdapterHTTP trait + reqwest implementation
-
Domain TypesSearchQuery, SearchResponse, result model
-
Domain TraitsSearchProvider + ProviderCapabilities
-
Domain Errors / EnumsSearchError, SearchType, SafeSearch, TimeRange
-
-
- -

Ideal import rules shown by the map

-
App sees traits, not providers

app imports SearchProvider, not BraveProvider or ExaProvider.

-
Bootstrap sees concretes

bootstrap is allowed to import app services, providers, transport, and domain.

-
Domain has no upward arrows

The core imports no CLI, app, provider, or transport modules.

-
-
- - diff --git a/docs/ideal-dependency-architecture-map.md b/docs/ideal-dependency-architecture-map.md deleted file mode 100644 index 78d01de..0000000 --- a/docs/ideal-dependency-architecture-map.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -title: "Ideal Dependency Architecture Map" -when_to_read: - - "When you need the clean target dependency architecture for sophon-cli." - - "When deciding whether a Rust import points in the intended direction." -summary: "Clean ideal architecture map for sophon-cli showing the allowed top-to-bottom dependency direction across entrypoint, CLI, bootstrap, app, adapters, transport, and domain." -ontology_relations: - - relation: "part_of" - target: "docs/SUMMARY.md" - note: "Belongs to the mdBook documentation set." - - relation: "explains" - target: "docs/architecture.md" - note: "Visualizes the ideal dependency direction behind the architecture layers." - - relation: "compares_with" - target: "docs/dependency-architecture-map.md" - note: "Pairs ideal target architecture with the current architecture map." ---- - -# Ideal Dependency Architecture Map - -This is the clean target architecture for Rust import direction. - -Read the map top-to-bottom: - -```text -higher layer imports lower layer -``` - -The ideal shape is: - -```text -Entrypoint - ↓ -CLI surface - ↓ -Bootstrap / composition root - ↓ -Application orchestration - ↓ -Adapters: providers + transport - ↓ -Domain core -``` - -Open the standalone visual page: - -[Ideal dependency architecture map](ideal-dependency-architecture-map.html) - -Compare with: - -[Current dependency architecture map](dependency-architecture-map.md) - - - -If the embedded frame is cramped, open the standalone page above. diff --git a/docs/import-organization.md b/docs/import-organization.md deleted file mode 100644 index 245da7d..0000000 --- a/docs/import-organization.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -title: "Import Organization" -when_to_read: - - "When changing Rust module boundaries or adding imports across runtime layers." - - "When checking whether a new module belongs in CLI, bootstrap, app, providers, transport, or domain." -summary: "Maintainer guidance for the allowed import direction in sophon-cli after the CLI runner and request-normalization refactor." -ontology_relations: - - relation: "explains" - target: "docs/architecture.md" - note: "Documents the import rules behind the architecture page." - - relation: "explains" - target: "docs/dependency-architecture-map.md" - note: "Describes the allowed edges shown by the current dependency map." ---- - -# Import Organization - -Keep imports flowing from user-facing edges toward stable domain contracts. Do not move concrete provider construction into the application layer, and do not let provider-specific details leak into domain types. - -## Allowed imports by layer - -| Layer | Allowed direction | -|-------|-------------------| -| `src/main.rs` | `main` to CLI runner only for runtime delegation, plus process setup dependencies such as tracing and dotenv | -| `src/cli/` | CLI to bootstrap, domain, args, request, and output helpers; the CLI runner owns provider-mode branching and rendering | -| `src/bootstrap/` | bootstrap to app, providers, transport, and domain; this is the composition root for concrete services | -| `src/app/` | app to domain only; services orchestrate `SearchProvider` trait objects | -| `src/providers/` | providers to transport and domain only, plus provider-local DTO, mapper, and config modules | -| `src/transport/` | transport to domain only for shared `SearchError` contracts | -| `src/domain/` | domain to no outer layers | - -## Review checklist - -- `src/main.rs` should continue delegating to `sophon_cli::cli::runner::run_from_env().await`. -- `src/cli/request.rs` should remain the place where `CliArgs` plus query text become `SearchQuery`. -- `src/cli/runner.rs` may request services from `ProviderRegistry`, but concrete provider construction must stay in `src/bootstrap/provider_registry.rs`. -- `src/app/` modules should accept domain types and domain traits only. -- `src/providers/` modules should map provider DTOs into domain responses before crossing back upward. -- `src/transport/` should return domain errors rather than provider-specific error types. diff --git a/tests/architecture_test.rs b/tests/architecture_test.rs index 823e35d..1b2b4ff 100644 --- a/tests/architecture_test.rs +++ b/tests/architecture_test.rs @@ -178,30 +178,7 @@ fn test_t004_ideal_dependency_direction_import_contract() { } #[test] -fn test_t005_import_organization_docs_contract() { - let import_docs = read_repo_file("docs/import-organization.md"); - assert!( - import_docs.starts_with("---\n"), - "T005 requires docs/import-organization.md to have YAML frontmatter" - ); - - let required_guidance = [ - "main", - "CLI runner", - "CLI to bootstrap", - "bootstrap to app", - "app to domain only", - "providers to transport", - "transport to domain", - "domain to no outer layers", - ]; - for guidance in required_guidance { - assert!( - import_docs.contains(guidance), - "T005 import organization docs missing guidance phrase {guidance:?}" - ); - } - +fn test_t005_current_dependency_map_reflects_refactor() { let current_map = read_repo_file("docs/dependency-architecture-map.html"); assert!( current_map.contains("cli::runner"), From 6eaa5a961ead7ca569088af686e84a8f5b08dc71 Mon Sep 17 00:00:00 2001 From: larock22 Date: Tue, 28 Apr 2026 17:36:23 -0500 Subject: [PATCH 43/44] Update docs navigation --- docs/SUMMARY.md | 4 +- docs/dependency-architecture-map.html | 2 +- docs/dependency-architecture-map.md | 10 +- docs/response-shape.md | 223 ++++++++++++++++++++++++++ 4 files changed, 226 insertions(+), 13 deletions(-) create mode 100644 docs/response-shape.md diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index b65a53a..e1873a3 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -3,8 +3,6 @@ [Introduction](intro.md) - [Architecture](architecture.md) -- [Dependency Direction Visual](dependency-direction.md) - [Current Dependency Architecture Map](dependency-architecture-map.md) -- [Ideal Dependency Architecture Map](ideal-dependency-architecture-map.md) -- [Import Organization Visual](import-organization.md) +- [Response Shape](response-shape.md) - [Quick Start](quickstart.md) diff --git a/docs/dependency-architecture-map.html b/docs/dependency-architecture-map.html index 1fe888d..b6f8338 100644 --- a/docs/dependency-architecture-map.html +++ b/docs/dependency-architecture-map.html @@ -363,7 +363,7 @@

sophon-cli dependency architecture

-
Generated for docs/dependency-architecture-map.html. Compare with docs/ideal-dependency-architecture-map.html.
+
Generated for docs/dependency-architecture-map.html.
diff --git a/docs/dependency-architecture-map.md b/docs/dependency-architecture-map.md index d27d03b..edd0ee2 100644 --- a/docs/dependency-architecture-map.md +++ b/docs/dependency-architecture-map.md @@ -2,8 +2,7 @@ title: "Current Dependency Architecture Map" when_to_read: - "When you need to see the current Rust import/dependency direction as a clean architecture map." - - "When comparing the current module import shape against the ideal architecture map." -summary: "Current architecture-style map of sophon-cli module import direction, shown in the same visual language as the ideal dependency architecture map." +summary: "Current architecture-style map of sophon-cli module import direction." ontology_relations: - relation: "part_of" target: "docs/SUMMARY.md" @@ -11,9 +10,6 @@ ontology_relations: - relation: "explains" target: "docs/architecture.md" note: "Visualizes the concrete import direction behind the architecture layers." - - relation: "compares_with" - target: "docs/ideal-dependency-architecture-map.md" - note: "Pairs current architecture with ideal target architecture." --- # Current Dependency Architecture Map @@ -30,10 +26,6 @@ Open the standalone visual page: [Current dependency architecture map](dependency-architecture-map.html) -Compare with: - -[Ideal dependency architecture map](ideal-dependency-architecture-map.md) -