Skip to main content

talon_core/search/
input.rs

1//! Search tool input types.
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7use crate::config::SearchConfig;
8use crate::constants::{DEFAULT_LIMIT, RELATED_DEFAULT_DEPTH};
9use crate::contracts::PositiveCount;
10use crate::error::TalonResult;
11use crate::search::constants::CANDIDATE_FLOOR_U16;
12
13/// Search mode.
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "kebab-case")]
16pub enum SearchMode {
17    /// Hybrid lexical plus semantic search.
18    #[default]
19    Hybrid,
20    /// Semantic-only search.
21    Semantic,
22    /// Full-text search.
23    Fulltext,
24    /// Title and alias search.
25    Title,
26}
27
28/// Related-note traversal direction.
29#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "kebab-case")]
31pub enum Direction {
32    /// Outgoing wikilinks.
33    Outgoing,
34    /// Backlinks.
35    Backlinks,
36    /// Outgoing wikilinks and backlinks.
37    #[default]
38    Both,
39}
40
41/// Frontmatter filter accepted by search and related queries.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(untagged)]
44pub enum FrontmatterFilter {
45    /// Tag-like frontmatter shorthand.
46    Text(String),
47    /// Any-of string values.
48    Texts(Vec<String>),
49    /// Exact key/value matches.
50    Fields(BTreeMap<String, FrontmatterValue>),
51}
52
53pub use crate::text::frontmatter::{FrontmatterValue, FrontmatterValueType};
54
55/// `--where` filter operator.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "kebab-case")]
58pub enum WhereOperator {
59    Equals,
60    NotEquals,
61    LessThan,
62    LessThanOrEqual,
63    GreaterThan,
64    GreaterThanOrEqual,
65    Contains,
66    Exists,
67    /// Starts-with / prefix match (`^=`).
68    StartsWith,
69    /// Glob pattern match (`~=`). Uses [`globset`] syntax.
70    GlobMatch,
71}
72
73/// A single `--where` filter clause.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct WhereClause {
76    /// Frontmatter key to filter on.
77    pub key: String,
78    /// Comparison operator.
79    pub op: WhereOperator,
80    /// Value to compare against (omitted for `exists`).
81    pub value: Option<String>,
82}
83
84/// Search request.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "camelCase")]
87pub struct SearchInput {
88    /// Primary query.
89    pub query: Option<String>,
90    /// Batch queries.
91    #[serde(default)]
92    pub queries: Vec<String>,
93    /// Disambiguating context for expansion, rerank, and chunk selection.
94    #[serde(
95        default,
96        deserialize_with = "crate::search::intent::deserialize_optional",
97        skip_serializing_if = "Option::is_none"
98    )]
99    pub intent: Option<String>,
100    /// Search mode.
101    #[serde(default)]
102    pub mode: SearchMode,
103    /// Lexical-only search when true.
104    #[serde(default)]
105    pub fast: bool,
106    /// Result limit.
107    #[serde(default)]
108    pub limit: PositiveCount,
109    /// Candidate pool size for RRF/rerank over-fetch.
110    #[serde(default)]
111    pub candidate_limit: PositiveCount,
112    /// Optional path scope.
113    #[serde(default)]
114    pub path: Option<String>,
115    /// Optional tag scope.
116    #[serde(default)]
117    pub tag: Vec<String>,
118    /// Optional frontmatter filter.
119    #[serde(default)]
120    pub frontmatter: Option<FrontmatterFilter>,
121    /// Include related notes.
122    #[serde(default)]
123    pub related: bool,
124    /// Related traversal depth.
125    #[serde(default = "default_depth")]
126    pub depth: u8,
127    /// Related traversal direction.
128    #[serde(default)]
129    pub direction: Direction,
130    /// Scope names to include (additive).
131    #[serde(default)]
132    pub scope: Vec<String>,
133    /// Scope names to search exclusively (mutually exclusive with `scope`).
134    #[serde(default)]
135    pub scope_only: Vec<String>,
136    /// Include every configured scope, overriding `default = false`.
137    #[serde(default)]
138    pub scope_all: bool,
139    /// Frontmatter `--where` filters (AND-composed).
140    #[serde(default)]
141    pub where_: Vec<WhereClause>,
142    /// Filter results indexed since this timestamp.
143    #[serde(default)]
144    pub since: Option<String>,
145    /// Include per-result `previewAnchors` (BM25 + semantic). Opt-in; adds
146    /// one extra DB lookup per result so is off by default.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub anchors: Option<bool>,
149}
150
151impl Default for SearchInput {
152    fn default() -> Self {
153        Self {
154            query: None,
155            queries: Vec::new(),
156            intent: None,
157            mode: SearchMode::Hybrid,
158            fast: false,
159            limit: PositiveCount::from_const(DEFAULT_LIMIT),
160            candidate_limit: PositiveCount::from_const(CANDIDATE_FLOOR_U16),
161            path: None,
162            tag: Vec::new(),
163            frontmatter: None,
164            related: false,
165            depth: RELATED_DEFAULT_DEPTH,
166            direction: Direction::Both,
167            scope: Vec::new(),
168            scope_only: Vec::new(),
169            scope_all: false,
170            where_: Vec::new(),
171            since: None,
172            anchors: None,
173        }
174    }
175}
176
177impl SearchInput {
178    /// Builds a CLI search request from validated command arguments.
179    ///
180    /// # Errors
181    ///
182    /// Returns [`TalonError::InvalidInput`] when `limit` or `candidate_limit` is zero.
183    pub fn from_cli_query(
184        query: String,
185        intent: Option<String>,
186        mode: SearchMode,
187        fast: bool,
188        limit: Option<u16>,
189        candidate_limit: Option<u16>,
190        config: Option<&SearchConfig>,
191    ) -> TalonResult<Self> {
192        let mut input = Self {
193            query: Some(query),
194            intent: crate::search::intent::normalize_optional(intent),
195            mode,
196            fast,
197            ..Self::from_search_config(config)?
198        };
199        if let Some(limit) = limit {
200            input.limit = PositiveCount::new(limit, "limit")?;
201        }
202        if let Some(candidate_limit) = candidate_limit {
203            input.candidate_limit = PositiveCount::new(candidate_limit, "candidate_limit")?;
204        }
205        Ok(input)
206    }
207
208    /// Builds a search request seeded from configured defaults.
209    ///
210    /// # Errors
211    ///
212    /// Returns [`TalonError::InvalidInput`] when configured `limit` or
213    /// `candidate_limit` is zero.
214    pub fn from_search_config(config: Option<&SearchConfig>) -> TalonResult<Self> {
215        let Some(config) = config else {
216            return Ok(Self::default());
217        };
218
219        Ok(Self {
220            limit: PositiveCount::new(config.limit, "limit")?,
221            candidate_limit: PositiveCount::new(config.candidate_limit, "candidate_limit")?,
222            ..Self::default()
223        })
224    }
225}
226
227const fn default_depth() -> u8 {
228    RELATED_DEFAULT_DEPTH
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::config::SearchConfig;
235
236    #[test]
237    fn from_cli_query_uses_config_defaults_when_flags_are_absent() {
238        let config = SearchConfig {
239            candidate_limit: 60,
240            limit: 12,
241            ..SearchConfig::default()
242        };
243
244        let input = SearchInput::from_cli_query(
245            "hello".to_string(),
246            None,
247            SearchMode::Hybrid,
248            false,
249            None,
250            None,
251            Some(&config),
252        )
253        .unwrap_or_else(|err| panic!("search input should build: {err}"));
254
255        assert_eq!(input.limit.get(), 12);
256        assert_eq!(input.candidate_limit.get(), 60);
257    }
258
259    #[test]
260    fn from_cli_query_flags_override_config_defaults() {
261        let config = SearchConfig {
262            candidate_limit: 60,
263            limit: 12,
264            ..SearchConfig::default()
265        };
266
267        let input = SearchInput::from_cli_query(
268            "hello".to_string(),
269            None,
270            SearchMode::Hybrid,
271            false,
272            Some(20),
273            Some(80),
274            Some(&config),
275        )
276        .unwrap_or_else(|err| panic!("search input should build: {err}"));
277
278        assert_eq!(input.limit.get(), 20);
279        assert_eq!(input.candidate_limit.get(), 80);
280    }
281}