1use std::collections::{HashMap, HashSet};
21
22use crate::identity::{artifact_identifier, artifact_identifiers};
23use crate::markdown::SearchSection;
24use crate::parse::Artifact;
25use crate::pycompat::{first_nonempty_line, py_casefold, py_round, py_strip};
26use crate::relationships::{
27 corpus_items, edge_spec, resolution_index_from_rows, validation_row, CorpusItem,
28};
29use crate::spec::spec_for;
30
31pub const OUTCOME_RESOLVED: &str = "resolved";
32pub const OUTCOME_NOT_FOUND: &str = "not-found";
33pub const OUTCOME_DUPLICATE: &str = "duplicate";
34
35const RANK_ID: i64 = 0;
38const RANK_TITLE: i64 = 1;
39const RANK_TAGS: i64 = 2;
40const RANK_PATH: i64 = 3;
41const RANK_HEADING: i64 = 4;
42const RANK_BODY: i64 = 5;
43
44fn rank_name(rank: i64) -> &'static str {
45 match rank {
46 RANK_ID => "id",
47 RANK_TITLE => "title",
48 RANK_TAGS => "tags",
49 RANK_PATH => "path",
50 RANK_HEADING => "heading",
51 _ => "body",
52 }
53}
54
55const RRF_K: i64 = 60;
57const GRAPH_WEIGHT: f64 = 0.5;
58const BM25_K1: f64 = 1.2;
59const BM25_B: f64 = 0.75;
60
61const FIELD_BOOSTS: [(&str, f64); 6] = [
64 ("id", 4.0),
65 ("title", 3.0),
66 ("path", 2.0),
67 ("heading", 1.5),
68 ("body", 1.0),
69 ("tags", 2.5),
70];
71
72pub fn tokenize(text: &str) -> Vec<String> {
80 let mut tokens: Vec<String> = Vec::new();
81 for piece in text.split(|c: char| !c.is_ascii_alphanumeric()) {
82 if piece.is_empty() {
83 continue;
84 }
85 let bytes = piece.as_bytes();
86 let mut start = 0usize;
87 for i in 1..bytes.len() {
88 if bytes[i - 1].is_ascii_lowercase() && bytes[i].is_ascii_uppercase() {
89 tokens.push(piece[start..i].to_ascii_lowercase());
90 start = i;
91 }
92 }
93 tokens.push(piece[start..].to_ascii_lowercase());
94 }
95 tokens
96}
97
98fn term_hits_tokens(term: &str, tokens: &[String]) -> bool {
100 tokens.iter().any(|t| t.starts_with(term))
101}
102
103fn tf(term: &str, tokens: &[String]) -> i64 {
105 tokens.iter().filter(|t| t.starts_with(term)).count() as i64
106}
107
108#[derive(Debug, Clone)]
114pub struct IndexEntry {
115 pub id: String,
116 pub artifact_type: String,
117 pub title: Option<String>,
118 pub path: String,
119 pub aliases: Vec<String>,
121 pub search_sections: Vec<SearchSection>,
122 pub inbound_count: i64,
124 pub tags: Vec<String>,
126}
127
128pub(crate) fn identity_entry_from_item(item: &CorpusItem) -> IndexEntry {
133 let artifact_type = item
134 .spec
135 .map(|s| s.name.clone())
136 .unwrap_or_else(|| "unknown".to_string());
137 IndexEntry {
138 id: artifact_identifier(&item.artifact, item.spec, &item.path),
139 artifact_type,
140 title: item.artifact.product.title.clone(),
141 path: item.path.clone(),
142 aliases: artifact_identifiers(&item.artifact, item.spec, &item.path),
143 search_sections: Vec::new(),
144 inbound_count: 0,
145 tags: Vec::new(),
146 }
147}
148
149pub(crate) fn entry_from_item(item: &CorpusItem, inbound: i64) -> IndexEntry {
150 IndexEntry {
151 search_sections: item.artifact.product.search_sections.clone(),
152 inbound_count: inbound,
153 tags: item
154 .artifact
155 .metadata
156 .as_ref()
157 .map(|m| m.tags.clone())
158 .unwrap_or_default(),
159 ..identity_entry_from_item(item)
160 }
161}
162
163fn inbound_counts(items: &[CorpusItem]) -> HashMap<String, i64> {
167 let rows: Vec<_> = items
168 .iter()
169 .map(|item| validation_row(&item.path, &item.artifact, item.spec))
170 .collect();
171 let index = resolution_index_from_rows(&rows);
172 let mut counts: HashMap<String, i64> = HashMap::new();
173 for row in &rows {
174 for (section, refs) in &row.edges {
175 let external = edge_spec(section).map(|e| e.external).unwrap_or(false);
176 if external {
177 continue;
178 }
179 for r in refs {
180 let targets = index.get(&py_casefold(r));
181 if targets.len() == 1 && targets[0].0 != row.path {
182 *counts.entry(targets[0].0.clone()).or_insert(0) += 1;
183 }
184 }
185 }
186 }
187 counts
188}
189
190pub fn build_index(directory: &str, recursive: bool) -> Vec<IndexEntry> {
193 index_from_items(&corpus_items(directory, recursive))
194}
195
196pub fn index_from_items(items: &[CorpusItem]) -> Vec<IndexEntry> {
197 let inbound = inbound_counts(items);
198 items
199 .iter()
200 .map(|item| entry_from_item(item, *inbound.get(&item.path).unwrap_or(&0)))
201 .collect()
202}
203
204#[derive(Debug, Clone)]
210pub struct ResolvedArtifact {
211 pub id: String,
212 pub artifact_type: String,
213 pub title: Option<String>,
214 pub path: String,
215 pub section: Option<String>,
216 pub snippet: Option<String>,
217 pub evidence: Option<Evidence>,
218 pub recency: Option<Recency>,
219 pub tags: Vec<String>,
220}
221
222#[derive(Debug, Clone)]
226pub struct Evidence {
227 pub field: &'static str,
228 pub terms: Vec<String>,
230 pub tier: i64,
231 pub score: f64,
233 pub bm25: f64,
235 pub lexical_rank: i64,
236 pub graph_rank: i64,
237 pub inbound: i64,
238 pub bm25_raw: f64,
240 pub fused_raw: f64,
242}
243
244#[derive(Debug, Clone)]
246pub struct Recency {
247 pub last_committed: Option<String>,
248 pub age_days: Option<i64>,
249 pub stale: Option<bool>,
250}
251
252#[derive(Debug, Clone)]
254pub struct ResolutionResult {
255 pub artifact_id: String,
257 pub outcome: &'static str,
258 pub artifact: Option<ResolvedArtifact>,
259 pub duplicate_paths: Vec<String>,
260}
261
262pub(crate) fn resolved_from_entry(entry: &IndexEntry) -> ResolvedArtifact {
263 ResolvedArtifact {
264 id: entry.id.clone(),
265 artifact_type: entry.artifact_type.clone(),
266 title: entry.title.clone(),
267 path: entry.path.clone(),
268 section: None,
269 snippet: None,
270 evidence: None,
271 recency: None,
272 tags: entry.tags.clone(),
273 }
274}
275
276pub fn resolve_in_index(entries: &[IndexEntry], artifact_id: &str) -> ResolutionResult {
279 let wanted = py_casefold(py_strip(artifact_id));
280 let matches: Vec<&IndexEntry> = entries
281 .iter()
282 .filter(|e| e.aliases.iter().any(|a| py_casefold(a) == wanted))
283 .collect();
284 if matches.is_empty() {
285 return ResolutionResult {
286 artifact_id: artifact_id.to_string(),
287 outcome: OUTCOME_NOT_FOUND,
288 artifact: None,
289 duplicate_paths: Vec::new(),
290 };
291 }
292 if matches.len() > 1 {
293 let mut paths: Vec<String> = matches.iter().map(|e| e.path.clone()).collect();
294 paths.sort(); return ResolutionResult {
296 artifact_id: artifact_id.to_string(),
297 outcome: OUTCOME_DUPLICATE,
298 artifact: None,
299 duplicate_paths: paths,
300 };
301 }
302 ResolutionResult {
303 artifact_id: artifact_id.to_string(),
304 outcome: OUTCOME_RESOLVED,
305 artifact: Some(resolved_from_entry(matches[0])),
306 duplicate_paths: Vec::new(),
307 }
308}
309
310pub fn resolve_artifact(directory: &str, artifact_id: &str, recursive: bool) -> ResolutionResult {
315 let items = corpus_items(directory, recursive);
316 let entries: Vec<IndexEntry> = items.iter().map(identity_entry_from_item).collect();
317 resolve_in_index(&entries, artifact_id)
318}
319
320#[derive(Debug, Clone, Default)]
326pub struct FieldTokens {
327 pub id: Vec<String>,
328 pub title: Vec<String>,
329 pub tags: Vec<String>,
330 pub path: Vec<String>,
331 pub heading: Vec<String>,
332 pub body: Vec<String>,
333}
334
335impl FieldTokens {
336 pub(crate) fn get(&self, name: &str) -> &Vec<String> {
337 match name {
338 "id" => &self.id,
339 "title" => &self.title,
340 "tags" => &self.tags,
341 "path" => &self.path,
342 "heading" => &self.heading,
343 _ => &self.body,
344 }
345 }
346}
347
348struct SectionTokens {
349 heading: String,
350 heading_tokens: Vec<String>,
351 lines: Vec<(String, Vec<String>)>,
352}
353
354pub(crate) struct EntryTokens {
355 pub(crate) fields: FieldTokens,
356 sections: Vec<SectionTokens>,
357}
358
359pub(crate) fn tokenize_entry(entry: &IndexEntry) -> EntryTokens {
360 let mut sections: Vec<SectionTokens> = Vec::new();
361 let mut heading_tokens: Vec<String> = Vec::new();
362 let mut body_tokens: Vec<String> = Vec::new();
363 for sec in &entry.search_sections {
364 let sec_heading_tokens = tokenize(&sec.heading);
365 heading_tokens.extend(sec_heading_tokens.iter().cloned());
366 let mut sec_lines: Vec<(String, Vec<String>)> = Vec::new();
367 for line in &sec.lines {
368 let line_tokens = tokenize(line);
369 body_tokens.extend(line_tokens.iter().cloned());
370 sec_lines.push((line.clone(), line_tokens));
371 }
372 sections.push(SectionTokens {
373 heading: sec.heading.clone(),
374 heading_tokens: sec_heading_tokens,
375 lines: sec_lines,
376 });
377 }
378 let mut id_tokens: Vec<String> = Vec::new();
379 for alias in &entry.aliases {
380 id_tokens.extend(tokenize(alias));
381 }
382 let mut tag_tokens: Vec<String> = Vec::new();
383 for tag in &entry.tags {
384 tag_tokens.extend(tokenize(tag));
385 }
386 EntryTokens {
387 fields: FieldTokens {
388 id: id_tokens,
389 title: tokenize(entry.title.as_deref().unwrap_or("")),
390 tags: tag_tokens,
391 path: tokenize(&entry.path),
392 heading: heading_tokens,
393 body: body_tokens,
394 },
395 sections,
396 }
397}
398
399pub(crate) fn field_tokens_of(entry: &IndexEntry) -> FieldTokens {
402 tokenize_entry(entry).fields
403}
404
405#[derive(Clone)]
410pub(crate) struct TierMatch {
411 rank: i64,
412 section: Option<String>,
413 snippet: Option<String>,
414 terms: Vec<String>,
416}
417
418fn match_fields(fields: &FieldTokens, terms: &[String]) -> Option<(i64, Vec<String>)> {
419 let mut matched_terms: HashSet<&str> = HashSet::new();
420 let mut best_rank: Option<i64> = None;
421 for (rank, field) in [
422 (RANK_ID, "id"),
423 (RANK_TITLE, "title"),
424 (RANK_TAGS, "tags"),
425 (RANK_PATH, "path"),
426 (RANK_HEADING, "heading"),
427 (RANK_BODY, "body"),
428 ] {
429 let tokens = fields.get(field);
430 let mut any = false;
431 for term in terms {
432 if term_hits_tokens(term, tokens) {
433 matched_terms.insert(term.as_str());
434 any = true;
435 }
436 }
437 if any && best_rank.is_none() {
438 best_rank = Some(rank);
439 }
440 }
441
442 let distinct: HashSet<&str> = terms.iter().map(|t| t.as_str()).collect();
444 if !distinct.is_subset(&matched_terms) {
445 return None;
446 }
447 let best_rank = best_rank?;
448
449 let mut seen: HashSet<&str> = HashSet::new();
451 let mut ordered: Vec<String> = Vec::new();
452 for term in terms {
453 if seen.insert(term.as_str()) && matched_terms.contains(term.as_str()) {
454 ordered.push(term.clone());
455 }
456 }
457 Some((best_rank, ordered))
458}
459
460fn any_term_hits(terms: &[String], tokens: &[String]) -> bool {
461 terms.iter().any(|term| term_hits_tokens(term, tokens))
462}
463
464pub(crate) fn match_entry(entry_tokens: &EntryTokens, terms: &[String]) -> Option<TierMatch> {
465 let (best_rank, ordered) = match_fields(&entry_tokens.fields, terms)?;
466
467 let snippet = match best_rank {
469 RANK_HEADING => entry_tokens
470 .sections
471 .iter()
472 .find(|section| any_term_hits(terms, §ion.heading_tokens))
473 .map(|section| (section.heading.clone(), section.heading.clone())),
474 RANK_BODY => entry_tokens.sections.iter().find_map(|section| {
475 section
476 .lines
477 .iter()
478 .find(|(_, tokens)| any_term_hits(terms, tokens))
479 .map(|(line, _)| (section.heading.clone(), line.clone()))
480 }),
481 _ => None,
482 };
483 let (section, snippet) = match snippet {
484 Some((section, line)) => (Some(section), Some(line)),
485 None => (None, None),
486 };
487 Some(TierMatch {
488 rank: best_rank,
489 section,
490 snippet,
491 terms: ordered,
492 })
493}
494
495pub(crate) fn match_entry_with_fields(
499 entry: &IndexEntry,
500 fields: &FieldTokens,
501 terms: &[String],
502) -> Option<TierMatch> {
503 let (best_rank, ordered) = match_fields(fields, terms)?;
504 let snippet = match best_rank {
505 RANK_HEADING => entry.search_sections.iter().find_map(|section| {
506 let tokens = tokenize(§ion.heading);
507 any_term_hits(terms, &tokens)
508 .then(|| (section.heading.clone(), section.heading.clone()))
509 }),
510 RANK_BODY => entry.search_sections.iter().find_map(|section| {
511 section.lines.iter().find_map(|line| {
512 let tokens = tokenize(line);
513 any_term_hits(terms, &tokens).then(|| (section.heading.clone(), line.clone()))
514 })
515 }),
516 _ => None,
517 };
518 let (section, snippet) = match snippet {
519 Some((section, line)) => (Some(section), Some(line)),
520 None => (None, None),
521 };
522 Some(TierMatch {
523 rank: best_rank,
524 section,
525 snippet,
526 terms: ordered,
527 })
528}
529
530pub struct CorpusStats {
536 pub n: i64,
537 pub df: HashMap<String, i64>,
539 pub avglen: [f64; 6],
541}
542
543fn corpus_stats(field_tokens: &[FieldTokens], terms: &[String]) -> CorpusStats {
544 let n = field_tokens.len() as i64;
545 let mut length_sums = [0i64; 6];
546 let mut df: HashMap<String, i64> = HashMap::new();
547 for term in terms {
548 df.entry(term.clone()).or_insert(0);
549 }
550 for fields in field_tokens {
551 for (i, (name, _)) in FIELD_BOOSTS.iter().enumerate() {
552 length_sums[i] += fields.get(name).len() as i64;
553 }
554 for term in terms {
556 if FIELD_BOOSTS
557 .iter()
558 .any(|(name, _)| tf(term, fields.get(name)) != 0)
559 {
560 *df.get_mut(term.as_str()).expect("df pre-seeded") += 1;
561 }
562 }
563 }
564 let mut avglen = [0.0f64; 6];
565 for (i, sum) in length_sums.iter().enumerate() {
566 avglen[i] = if n != 0 { *sum as f64 / n as f64 } else { 0.0 };
567 }
568 CorpusStats { n, df, avglen }
569}
570
571pub fn stats_for(entries: &[IndexEntry], query: &str) -> CorpusStats {
574 let terms = tokenize(query);
575 let field_tokens: Vec<FieldTokens> = entries
576 .iter()
577 .map(|e| tokenize_entry(e).fields)
578 .collect();
579 corpus_stats(&field_tokens, &terms)
580}
581
582fn bm25f(fields: &FieldTokens, terms: &[String], stats: &CorpusStats) -> f64 {
584 let mut score = 0.0f64;
585 for term in terms {
586 let d = *stats.df.get(term.as_str()).unwrap_or(&0);
588 if d == 0 {
589 continue;
590 }
591 let num = (stats.n - d) as f64 + 0.5;
593 let den = d as f64 + 0.5;
594 let idf = (1.0 + num / den).ln();
595 let mut weighted_tf = 0.0f64;
596 for (i, (name, boost)) in FIELD_BOOSTS.iter().enumerate() {
597 let tokens = fields.get(name);
598 let tfv = tf(term, tokens);
599 if tfv == 0 {
600 continue; }
602 let length = tokens.len() as i64;
603 let mean = stats.avglen[i];
604 let denom = if mean > 0.0 {
605 1.0 - BM25_B + BM25_B * (length as f64 / mean)
606 } else {
607 1.0
608 };
609 weighted_tf += boost * (tfv as f64 / denom);
610 }
611 if weighted_tf > 0.0 {
612 score += idf * (weighted_tf / (BM25_K1 + weighted_tf));
613 }
614 }
615 score
616}
617
618fn competition_ranks(scores: &[f64], paths: &[&str]) -> Vec<i64> {
621 let mut ordered: Vec<usize> = (0..scores.len()).collect();
622 ordered.sort_by(|&a, &b| {
623 scores[b]
624 .partial_cmp(&scores[a])
625 .expect("finite score")
626 .then_with(|| paths[a].cmp(paths[b]))
627 });
628 let mut ranks = vec![0; scores.len()];
629 let mut previous: Option<f64> = None;
630 let mut rank = 0i64;
631 for (position, &index) in ordered.iter().enumerate() {
632 let position = position as i64 + 1;
633 if Some(scores[index]) != previous {
634 rank = position;
635 previous = Some(scores[index]);
636 }
637 ranks[index] = rank;
638 }
639 ranks
640}
641
642#[derive(Debug, Clone)]
648pub struct SearchResult {
649 pub query: String,
650 pub artifact_type: Option<String>,
652 pub matches: Vec<ResolvedArtifact>,
653}
654
655pub(crate) fn entry_has_tags(entry: &IndexEntry, wanted: &[String]) -> bool {
657 let have: HashSet<String> = entry.tags.iter().map(|t| py_casefold(t)).collect();
658 wanted.iter().all(|w| have.contains(w))
659}
660
661pub fn search_index(
664 entries: &[IndexEntry],
665 query: &str,
666 artifact_type: Option<&str>,
667 tags: &[String],
668) -> SearchResult {
669 search_index_filtered(entries, query, artifact_type, tags, false)
670}
671
672pub fn entry_is_retired(entry: &IndexEntry) -> bool {
676 let status = artifact_status(&crate::parse::parse_file(&entry.path));
677 is_retired_status(&entry.artifact_type, &status)
678}
679
680pub fn is_retired_status(artifact_type: &str, status: &str) -> bool {
684 let Some(spec) = spec_for(artifact_type) else {
685 return false;
686 };
687 let wanted = py_casefold(status);
688 spec.retired_status.iter().any(|s| py_casefold(s) == wanted)
689}
690
691pub fn search_index_filtered(
696 entries: &[IndexEntry],
697 query: &str,
698 artifact_type: Option<&str>,
699 tags: &[String],
700 live_only: bool,
701) -> SearchResult {
702 let terms = tokenize(query);
703 let tag_filter: Vec<String> = tags.iter().map(|t| py_casefold(t)).collect();
704 let mut matched: Vec<(usize, TierMatch)> = Vec::new();
705 let mut tokenized: Vec<Option<EntryTokens>> = Vec::with_capacity(entries.len());
706 tokenized.resize_with(entries.len(), || None);
707 if !terms.is_empty() {
708 for (i, entry) in entries.iter().enumerate() {
709 if let Some(t) = artifact_type {
710 if entry.artifact_type != t {
711 continue;
712 }
713 }
714 if !tag_filter.is_empty() && !entry_has_tags(entry, &tag_filter) {
715 continue;
716 }
717 let entry_tokens = tokenize_entry(entry);
718 let m = match_entry(&entry_tokens, &terms);
719 tokenized[i] = Some(entry_tokens);
720 if let Some(m) = m {
721 matched.push((i, m));
722 }
723 }
724 }
725 if live_only && !matched.is_empty() {
729 matched.retain(|(i, _)| !entry_is_retired(&entries[*i]));
730 }
731 if matched.is_empty() {
732 return SearchResult {
733 query: query.to_string(),
734 artifact_type: artifact_type.map(str::to_string),
735 matches: Vec::new(),
736 };
737 }
738
739 let field_tokens: Vec<FieldTokens> = entries
741 .iter()
742 .enumerate()
743 .map(|(i, entry)| match tokenized[i].take() {
744 Some(t) => t.fields,
745 None => tokenize_entry(entry).fields,
746 })
747 .collect();
748 let stats = corpus_stats(&field_tokens, &terms);
749
750 let scored: Vec<(&IndexEntry, &FieldTokens, TierMatch)> = matched
751 .into_iter()
752 .map(|(i, m)| (&entries[i], &field_tokens[i], m))
753 .collect();
754 rank_and_build(query, artifact_type, scored, &terms, &stats)
755}
756
757pub(crate) fn rank_and_build(
763 query: &str,
764 artifact_type: Option<&str>,
765 matched: Vec<(&IndexEntry, &FieldTokens, TierMatch)>,
766 terms: &[String],
767 stats: &CorpusStats,
768) -> SearchResult {
769 let bm25_started = crate::timing::start();
771 let bm25_scores: Vec<f64> = matched
772 .iter()
773 .map(|(_, fields, _)| bm25f(fields, terms, stats))
774 .collect();
775 crate::timing::emit_since(
776 "search.bm25f",
777 bm25_started,
778 &[("matched", matched.len() as u64)],
779 );
780 let fusion_started = crate::timing::start();
781 let inbound_scores: Vec<f64> = matched
782 .iter()
783 .map(|(entry, _, _)| entry.inbound_count as f64)
784 .collect();
785 let paths: Vec<&str> = matched.iter().map(|(entry, _, _)| entry.path.as_str()).collect();
786 let lexical_rank = competition_ranks(&bm25_scores, &paths);
787 let graph_rank = competition_ranks(&inbound_scores, &paths);
788 let fused: Vec<f64> = bm25_scores
789 .iter()
790 .enumerate()
791 .map(|(index, _)| {
792 1.0 / ((RRF_K + lexical_rank[index]) as f64)
793 + GRAPH_WEIGHT / ((RRF_K + graph_rank[index]) as f64)
794 })
795 .collect();
796 crate::timing::emit_since(
797 "search.rank_fusion",
798 fusion_started,
799 &[("matched", matched.len() as u64)],
800 );
801
802 let sort_started = crate::timing::start();
805 let fused_sort_keys: Vec<f64> = fused.iter().map(|score| py_round(*score, 12)).collect();
806 let mut order: Vec<usize> = (0..matched.len()).collect();
807 order.sort_by(|&a, &b| {
808 fused_sort_keys[b]
809 .partial_cmp(&fused_sort_keys[a])
810 .expect("finite fused")
811 .then_with(|| paths[a].cmp(paths[b]))
812 });
813 crate::timing::emit_since(
814 "search.final_sort",
815 sort_started,
816 &[("matched", matched.len() as u64)],
817 );
818
819 let projection_started = crate::timing::start();
820 let matches: Vec<ResolvedArtifact> = order
821 .into_iter()
822 .map(|index| {
823 let (entry, _, m) = &matched[index];
824 let fused_raw = fused[index];
825 let bm25_raw = bm25_scores[index];
826 ResolvedArtifact {
827 id: entry.id.clone(),
828 artifact_type: entry.artifact_type.clone(),
829 title: entry.title.clone(),
830 path: entry.path.clone(),
831 section: m.section.clone(),
832 snippet: m.snippet.clone(),
833 evidence: Some(Evidence {
834 field: rank_name(m.rank),
835 terms: m.terms.clone(),
836 tier: m.rank,
837 score: py_round(fused_raw, 6),
838 bm25: py_round(bm25_raw, 6),
839 lexical_rank: lexical_rank[index],
840 graph_rank: graph_rank[index],
841 inbound: entry.inbound_count,
842 bm25_raw,
843 fused_raw,
844 }),
845 recency: None,
846 tags: entry.tags.clone(),
847 }
848 })
849 .collect();
850 crate::timing::emit_since(
851 "search.response_projection",
852 projection_started,
853 &[("matches", matches.len() as u64)],
854 );
855
856 SearchResult {
857 query: query.to_string(),
858 artifact_type: artifact_type.map(str::to_string),
859 matches,
860 }
861}
862
863pub fn find_artifacts(
865 directory: &str,
866 query: &str,
867 artifact_type: Option<&str>,
868 recursive: bool,
869 tags: &[String],
870 live_only: bool,
871) -> SearchResult {
872 let entries = build_index(directory, recursive);
873 search_index_filtered(&entries, query, artifact_type, tags, live_only)
874}
875
876pub fn artifact_status(artifact: &Artifact) -> String {
882 artifact
883 .section("status")
884 .map(first_nonempty_line)
885 .unwrap_or("")
886 .to_string()
887}
888
889pub(crate) fn is_live_decision(artifact: &Artifact) -> bool {
891 let status = py_casefold(&artifact_status(artifact));
892 if status != "accepted" {
893 return false;
894 }
895 let retired: Vec<String> = spec_for("decision")
896 .map(|s| s.retired_status.iter().map(|r| py_casefold(r)).collect())
897 .unwrap_or_default();
898 !retired.contains(&status)
899}
900
901pub fn find_decisions(directory: &str, topic: &str, recursive: bool) -> SearchResult {
905 let items = corpus_items(directory, recursive);
906 let live: HashSet<String> = items
907 .iter()
908 .filter(|item| {
909 item.spec.map(|s| s.name.as_str()) == Some("decision")
910 && is_live_decision(&item.artifact)
911 })
912 .map(|item| item.path.clone())
913 .collect();
914 let entries = index_from_items(&items);
915 let mut result = search_index(&entries, topic, Some("decision"), &[]);
916 result.matches.retain(|m| live.contains(&m.path));
917 result
918}
919
920#[cfg(test)]
921mod tests {
922 use super::*;
923
924 fn toks(s: &str) -> Vec<String> {
925 tokenize(s)
926 }
927
928 #[test]
929 fn tokenize_contract_examples() {
930 assert_eq!(toks("soft-delete"), vec!["soft", "delete"]);
931 assert_eq!(toks("camelCase"), vec!["camel", "case"]);
932 assert_eq!(toks("HTTPServer"), vec!["httpserver"]);
933 assert_eq!(
934 toks("MiXeD-Case_fooBAR"),
935 vec!["mi", "xe", "d", "case", "foo", "bar"]
936 );
937 assert_eq!(toks("v0.22.0"), vec!["v0", "22", "0"]);
938 assert_eq!(toks("ADR-037"), vec!["adr", "037"]);
939 assert_eq!(toks("foo_barBaz2Qux"), vec!["foo", "bar", "baz2qux"]);
940 assert_eq!(toks("caf\u{e9}"), vec!["caf"]);
941 assert_eq!(toks("e\u{301}clair"), vec!["e", "clair"]);
942 assert_eq!(toks("\u{130}stanbul"), vec!["stanbul"]);
943 assert_eq!(toks("Stra\u{df}e"), vec!["stra", "e"]);
944 assert_eq!(toks("..."), Vec::<String>::new());
945 assert_eq!(toks(""), Vec::<String>::new());
946 }
947
948 #[test]
949 fn prefix_matching_is_one_directional() {
950 let tokens = vec!["searching".to_string()];
951 assert!(term_hits_tokens("sear", &tokens));
952 assert!(term_hits_tokens("searching", &tokens));
953 assert!(!term_hits_tokens("searchingx", &tokens));
954 assert_eq!(tf("sear", &tokens), 1);
955 }
956
957 #[test]
958 fn competition_ranks_share_on_exact_equality() {
959 let scores = vec![2.0, 2.0, 1.0];
960 let paths = vec!["b", "a", "c"];
961 let ranks = competition_ranks(&scores, &paths);
962 assert_eq!(ranks, vec![1, 1, 3]);
963 }
964
965 #[test]
966 fn persisted_field_matching_preserves_tiers_and_snippets() {
967 let entry = IndexEntry {
968 id: "RAC-EXAMPLE1234".to_string(),
969 artifact_type: "requirement".to_string(),
970 title: Some("Search latency".to_string()),
971 path: "requirements/search-latency.md".to_string(),
972 aliases: vec!["RAC-EXAMPLE1234".to_string(), "legacy-search".to_string()],
973 search_sections: vec![
974 SearchSection {
975 heading: "Acceptance Criteria".to_string(),
976 lines: vec!["Warm lookup stays below budget".to_string()],
977 },
978 SearchSection {
979 heading: "Risks".to_string(),
980 lines: vec!["Corpus growth may affect sorting".to_string()],
981 },
982 ],
983 inbound_count: 2,
984 tags: vec!["performance".to_string()],
985 };
986 let tokenized = tokenize_entry(&entry);
987 for query in [
988 "example1234",
989 "search performance",
990 "acceptance",
991 "warm budget",
992 "growth sorting",
993 "missing",
994 "search search",
995 ] {
996 let terms = tokenize(query);
997 let fresh = match_entry(&tokenized, &terms);
998 let stored = match_entry_with_fields(&entry, &tokenized.fields, &terms);
999 assert_eq!(fresh.is_some(), stored.is_some(), "query {query:?}");
1000 if let (Some(fresh), Some(stored)) = (fresh, stored) {
1001 assert_eq!(fresh.rank, stored.rank, "query {query:?}: rank");
1002 assert_eq!(fresh.section, stored.section, "query {query:?}: section");
1003 assert_eq!(fresh.snippet, stored.snippet, "query {query:?}: snippet");
1004 assert_eq!(fresh.terms, stored.terms, "query {query:?}: terms");
1005 }
1006 }
1007 }
1008}