1use 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "kebab-case")]
16pub enum SearchMode {
17 #[default]
19 Hybrid,
20 Semantic,
22 Fulltext,
24 Title,
26}
27
28#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "kebab-case")]
31pub enum Direction {
32 Outgoing,
34 Backlinks,
36 #[default]
38 Both,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(untagged)]
44pub enum FrontmatterFilter {
45 Text(String),
47 Texts(Vec<String>),
49 Fields(BTreeMap<String, FrontmatterValue>),
51}
52
53pub use crate::text::frontmatter::{FrontmatterValue, FrontmatterValueType};
54
55#[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 StartsWith,
69 GlobMatch,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct WhereClause {
76 pub key: String,
78 pub op: WhereOperator,
80 pub value: Option<String>,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "camelCase")]
87pub struct SearchInput {
88 pub query: Option<String>,
90 #[serde(default)]
92 pub queries: Vec<String>,
93 #[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 #[serde(default)]
102 pub mode: SearchMode,
103 #[serde(default)]
105 pub fast: bool,
106 #[serde(default)]
108 pub limit: PositiveCount,
109 #[serde(default)]
111 pub candidate_limit: PositiveCount,
112 #[serde(default)]
114 pub path: Option<String>,
115 #[serde(default)]
117 pub tag: Vec<String>,
118 #[serde(default)]
120 pub frontmatter: Option<FrontmatterFilter>,
121 #[serde(default)]
123 pub related: bool,
124 #[serde(default = "default_depth")]
126 pub depth: u8,
127 #[serde(default)]
129 pub direction: Direction,
130 #[serde(default)]
132 pub scope: Vec<String>,
133 #[serde(default)]
135 pub scope_only: Vec<String>,
136 #[serde(default)]
138 pub scope_all: bool,
139 #[serde(default)]
141 pub where_: Vec<WhereClause>,
142 #[serde(default)]
144 pub since: Option<String>,
145 #[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 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 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}