use super::*; struct SearchPage { route_path: String, owner: String, repo_name: String, default_branch: String, raw_query: String, effective_query: String, requested_scope: String, scope: String, path_filter: Option, ext_filter: Option, scope_inferred: bool, state: SearchPageState, } enum SearchPageState { Idle, Code { results: Vec, total_matches: usize, }, Commits { results: Vec, }, } impl SearchPage { fn current_path(&self) -> String { self.search_ui_path(&self.scope) } fn search_ui_path(&self, scope: &str) -> String { build_query_path( &self.route_path, &[ ( "q", (!self.raw_query.is_empty()).then_some(self.raw_query.as_str()), ), ("scope", Some(scope)), ], ) } fn json_search_path(&self) -> String { build_query_path( &format!("/{}/{}/search", self.owner, self.repo_name), &[ ( "q", (!self.raw_query.is_empty()).then_some(self.raw_query.as_str()), ), ("scope", Some(self.scope.as_str())), ], ) } fn commit_path(&self, hash: &str) -> String { format!("/{}/{}/commit/{}", self.owner, self.repo_name, hash) } fn blob_path(&self, path: &str) -> String { format!( "/{}/{}/blob/{}/{}", self.owner, self.repo_name, self.default_branch, path ) } fn blob_line_path(&self, path: &str, line_number: usize) -> String { format!("{}#L{}", self.blob_path(path), line_number) } } fn build_search_page( sql: &SqlStorage, owner: &str, repo_name: &str, url: &Url, ) -> Result { let raw_query = api::get_query(url, "q").unwrap_or_default(); let requested_scope = api::get_query(url, "scope").unwrap_or_else(|| "code".to_string()); let parsed = api::parse_search_query(&raw_query); let effective_query = if parsed.fts_query.is_empty() { raw_query.clone() } else { parsed.fts_query.clone() }; let scope = parsed .scope .map(|value| value.to_string()) .unwrap_or_else(|| requested_scope.clone()); let scope_inferred = scope != requested_scope; let (default_branch, _) = resolve_default_branch(sql)?; let state = if raw_query.is_empty() && effective_query.is_empty() { SearchPageState::Idle } else if scope == "commits" { SearchPageState::Commits { results: store::search_commits(sql, &effective_query, 50)?, } } else { let results = store::search_code( sql, &effective_query, parsed.path_filter.as_deref(), parsed.ext_filter.as_deref(), 50, )?; let total_matches = results.iter().map(|result| result.matches.len()).sum(); SearchPageState::Code { results, total_matches, } }; Ok(SearchPage { route_path: url.path().to_string(), owner: owner.to_string(), repo_name: repo_name.to_string(), default_branch, raw_query, effective_query, requested_scope, scope, path_filter: parsed.path_filter, ext_filter: parsed.ext_filter, scope_inferred, state, }) } fn render_search_html(page: &SearchPage, actor_name: Option<&str>) -> String { let mut html = String::new(); html.push_str("

Search

"); let code_active = if page.scope == "code" { " style=\"font-weight:700;text-decoration:underline\"" } else { "" }; let commits_active = if page.scope == "commits" { " style=\"font-weight:700;text-decoration:underline\"" } else { "" }; html.push_str(&format!( r#""#, code_path = html_escape(&page.search_ui_path("code")), commits_path = html_escape(&page.search_ui_path("commits")), ca = code_active, cc = commits_active, )); html.push_str(&format!( r#"
"#, owner = page.owner, repo = page.repo_name, scope = html_escape(&page.scope), q = html_escape(&page.raw_query), )); match &page.state { SearchPageState::Idle => {} SearchPageState::Commits { results } => { if results.is_empty() { html.push_str("

No matching commits found.

"); } else { html.push_str(&format!( "

{} commit{} found

", results.len(), if results.len() == 1 { "" } else { "s" } )); html.push_str(r#"
    "#); for commit in results { html.push_str(&format!( r#"
  • {short} {msg} {author} {time}
  • "#, owner = page.owner, repo = page.repo_name, hash = commit.hash, short = &commit.hash[..7.min(commit.hash.len())], msg = html_escape(&first_line(&commit.message)), author = html_escape(&commit.author), time = format_time(commit.commit_time), )); } html.push_str("
"); } } SearchPageState::Code { results, total_matches, } => { if results.is_empty() { html.push_str("

No results found.

"); } else { html.push_str(&format!( "

{} match{} across {} file{}

", total_matches, if *total_matches == 1 { "" } else { "es" }, results.len(), if results.len() == 1 { "" } else { "s" }, )); for result in results { html.push_str(r#"
"#); html.push_str(&format!( r#"
{path} ({n} match{s})
"#, owner = page.owner, repo = page.repo_name, branch = page.default_branch, path = html_escape(&result.path), n = result.matches.len(), s = if result.matches.len() == 1 { "" } else { "es" }, )); html.push_str(r#""#); for item in &result.matches { html.push_str(&format!( r#""#, owner = page.owner, repo = page.repo_name, branch = page.default_branch, path = html_escape(&result.path), ln = item.line_number, text = html_escape(&item.line_text), )); } html.push_str("
{ln}{text}
"); html.push_str("
"); } } } } layout( "Search", &page.owner, &page.repo_name, &page.default_branch, actor_name, &html, ) } fn render_search_markdown(page: &SearchPage, selection: &NegotiatedRepresentation) -> String { let mut markdown = format!("# {}/{} search\n", page.owner, page.repo_name); markdown.push_str(&format!( "\nQuery: {}\nScope: `{}`\n", markdown_value(&page.raw_query), page.scope )); if page.scope_inferred { markdown.push_str(&format!("Requested scope: `{}`\n", page.requested_scope)); } if !page.effective_query.is_empty() && page.effective_query != page.raw_query { markdown.push_str(&format!( "Effective search terms: {}\n", markdown_value(&page.effective_query) )); } if let Some(path_filter) = &page.path_filter { markdown.push_str(&format!("Path filter: {}\n", markdown_value(path_filter))); } if let Some(ext_filter) = &page.ext_filter { markdown.push_str(&format!( "Extension filter: {}\n", markdown_value(ext_filter) )); } if page.scope == "code" { markdown.push_str(&format!("Indexed branch: `{}`\n", page.default_branch)); } markdown.push_str("\n## Scope Navigation (GET paths)\n"); markdown.push_str(&format!( "- `code`{} - `{}`\n", if page.scope == "code" { " (current)" } else { "" }, page.search_ui_path("code") )); markdown.push_str(&format!( "- `commits`{} - `{}`\n", if page.scope == "commits" { " (current)" } else { "" }, page.search_ui_path("commits") )); match &page.state { SearchPageState::Idle => { markdown.push_str("\nProvide `q=` to search code or commits.\n"); } SearchPageState::Code { results, total_matches, } => { markdown.push_str(&format!( "\nResults: `{}` match{} across `{}` file{}\n", total_matches, if *total_matches == 1 { "" } else { "es" }, results.len(), if results.len() == 1 { "" } else { "s" }, )); markdown.push_str("\n## Code Results (GET paths)\n"); if results.is_empty() { markdown.push_str("No code results found.\n"); } else { for result in results { markdown.push_str(&format!( "- `{}` - {} match{} - `{}`\n", result.path, result.matches.len(), if result.matches.len() == 1 { "" } else { "es" }, page.blob_path(&result.path) )); for item in &result.matches { markdown.push_str(&format!( " - `L{}` - {} - `{}`\n", item.line_number, concise_line_text(&item.line_text), page.blob_line_path(&result.path, item.line_number) )); } } } } SearchPageState::Commits { results } => { markdown.push_str(&format!( "\nResults: `{}` commit{}\n", results.len(), if results.len() == 1 { "" } else { "s" } )); markdown.push_str("\n## Commit Results (GET paths)\n"); if results.is_empty() { markdown.push_str("No matching commits found.\n"); } else { for commit in results { markdown.push_str(&format!( "- `{}` - {} - {} - {} - `{}`\n", &commit.hash[..7.min(commit.hash.len())], first_line(&commit.message), commit.author, format_time(commit.commit_time), page.commit_path(&commit.hash) )); } } } } let actions = vec![ Action::get(page.current_path(), "reload this search page"), Action::get(page.search_ui_path("code"), "switch to code search"), Action::get(page.search_ui_path("commits"), "switch to commit search"), Action::get( page.json_search_path(), "fetch the structured JSON search endpoint", ), ]; let mut hints = vec![ presentation::text_navigation_hint(*selection), Hint::new("Use `/search-ui` for the human-readable page and `/search` for structured JSON results."), Hint::new("Supported filters: `@author:`, `@message:`, `@path:`, `@ext:`, and `@content:`."), Hint::new("`@author:` and `@message:` imply commit scope; `@path:` and `@ext:` narrow code search results."), ]; if page.scope == "code" { hints.push(Hint::new(format!( "Code results come from the default branch index: `{}`.", page.default_branch ))); } markdown.push_str(&presentation::render_actions_section(&actions)); markdown.push_str(&presentation::render_hints_section(&hints)); markdown } fn build_query_path(base_path: &str, params: &[(&str, Option<&str>)]) -> String { let mut url = match Url::parse("https://ripgit.local/") { Ok(url) => url, Err(_) => return base_path.to_string(), }; url.set_path(base_path); { let mut query = url.query_pairs_mut(); for (key, value) in params { if let Some(value) = value { query.append_pair(key, value); } } } match url.query() { Some(query) if !query.is_empty() => format!("{}?{}", url.path(), query), _ => url.path().to_string(), } } fn markdown_value(value: &str) -> String { if value.is_empty() { "(empty)".to_string() } else { format!("`{}`", value) } } fn concise_line_text(text: &str) -> String { const MAX_CHARS: usize = 160; if text.chars().count() <= MAX_CHARS { return text.to_string(); } let mut output = String::new(); for (idx, ch) in text.chars().enumerate() { if idx >= MAX_CHARS - 3 { break; } output.push(ch); } output.push_str("..."); output } pub fn page_search( sql: &SqlStorage, owner: &str, repo_name: &str, url: &Url, actor_name: Option<&str>, ) -> Result { let page = build_search_page(sql, owner, repo_name, url)?; html_response(&render_search_html(&page, actor_name)) } pub fn page_search_markdown( sql: &SqlStorage, owner: &str, repo_name: &str, url: &Url, selection: &NegotiatedRepresentation, ) -> Result { let page = build_search_page(sql, owner, repo_name, url)?; presentation::markdown_response(&render_search_markdown(&page, selection), selection) }