1mod dataframe;
129mod models;
130mod schema;
131
132pub use models::{Note, NoteId, Tag, TagId, TagsMap};
133pub use polars::prelude as polars_prelude;
134
135use models::{note_from_row, tag_from_row};
136use polars::prelude::*;
137use rusqlite::{Connection, OpenFlags};
138use std::collections::HashSet;
139use std::path::PathBuf;
140use std::time::Duration;
141
142use dataframe::query_to_dataframe;
143
144#[derive(Debug, Clone)]
149enum DatabasePath {
150 RealPath(PathBuf),
152 #[cfg(test)]
154 InMemory,
155}
156
157impl DatabasePath {
158 fn open_connection(&self) -> Result<Connection, BearError> {
162 match self {
163 DatabasePath::RealPath(path) => {
164 let conn = Connection::open_with_flags(
168 path,
169 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
170 )?;
171
172 conn.busy_timeout(Duration::from_millis(5000))?;
174
175 conn.pragma_update(None, "query_only", "ON")?;
177
178 Ok(conn)
179 }
180 #[cfg(test)]
181 DatabasePath::InMemory => {
182 let conn = Connection::open_in_memory()?;
183 schema::setup_test_schema(&conn)?;
184 Ok(conn)
185 }
186 }
187 }
188}
189
190#[derive(Debug, thiserror::Error)]
191pub enum BearError {
192 #[error("Unable to load users home directory")]
193 NoHomeDirectory,
194 #[error("SQL Error: {source}")]
195 SqlError {
196 #[from]
197 source: rusqlite::Error,
198 },
199 #[error("Polars Error: {source}")]
200 PolarsError {
201 #[from]
202 source: PolarsError,
203 },
204}
205
206#[derive(Debug, Clone)]
234pub struct NotesQuery {
235 limit: Option<u32>,
236 include_trashed: bool,
237 include_archived: bool,
238}
239
240impl Default for NotesQuery {
241 fn default() -> Self {
242 Self {
243 limit: Some(10),
244 include_trashed: false,
245 include_archived: false,
246 }
247 }
248}
249
250impl NotesQuery {
251 pub fn new() -> Self {
253 Self::default()
254 }
255
256 pub fn limit(
258 mut self,
259 limit: u32,
260 ) -> Self {
261 self.limit = Some(limit);
262 self
263 }
264
265 pub fn no_limit(mut self) -> Self {
267 self.limit = None;
268 self
269 }
270
271 pub fn include_trashed(mut self) -> Self {
273 self.include_trashed = true;
274 self
275 }
276
277 pub fn include_archived(mut self) -> Self {
279 self.include_archived = true;
280 self
281 }
282
283 pub fn include_all(mut self) -> Self {
285 self.include_trashed = true;
286 self.include_archived = true;
287 self
288 }
289}
290
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
293pub enum SortOn {
294 Modified,
296 Created,
298 Title,
300}
301
302impl SortOn {
303 pub fn asc(self) -> SortOrder {
305 SortOrder::Asc(self)
306 }
307
308 pub fn desc(self) -> SortOrder {
310 SortOrder::Desc(self)
311 }
312}
313
314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub enum SortOrder {
317 Asc(SortOn),
319 Desc(SortOn),
321}
322
323impl Default for SortOrder {
324 fn default() -> Self {
325 SortOrder::Desc(SortOn::Modified)
326 }
327}
328
329impl SortOrder {
330 fn to_sql(&self) -> &'static str {
331 match self {
332 SortOrder::Desc(SortOn::Modified) => "modified DESC",
333 SortOrder::Asc(SortOn::Modified) => "modified ASC",
334 SortOrder::Desc(SortOn::Created) => "created DESC",
335 SortOrder::Asc(SortOn::Created) => "created ASC",
336 SortOrder::Asc(SortOn::Title) => "title ASC",
337 SortOrder::Desc(SortOn::Title) => "title DESC",
338 }
339 }
340}
341
342#[derive(Debug, Clone)]
388pub struct SearchQuery {
389 query: String,
390 search_title: bool,
391 search_content: bool,
392 case_sensitive: bool,
393 limit: Option<u32>,
394 sort_by: SortOrder,
395 include_trashed: bool,
396 include_archived: bool,
397}
398
399impl SearchQuery {
400 pub fn new(query: impl Into<String>) -> Self {
409 Self {
410 query: query.into(),
411 search_title: true,
412 search_content: true,
413 case_sensitive: false,
414 limit: Some(50),
415 sort_by: SortOrder::default(),
416 include_trashed: false,
417 include_archived: false,
418 }
419 }
420
421 pub fn title_only(mut self) -> Self {
423 self.search_title = true;
424 self.search_content = false;
425 self
426 }
427
428 pub fn content_only(mut self) -> Self {
430 self.search_title = false;
431 self.search_content = true;
432 self
433 }
434
435 pub fn title_and_content(mut self) -> Self {
437 self.search_title = true;
438 self.search_content = true;
439 self
440 }
441
442 pub fn case_sensitive(mut self) -> Self {
444 self.case_sensitive = true;
445 self
446 }
447
448 pub fn limit(
450 mut self,
451 limit: u32,
452 ) -> Self {
453 self.limit = Some(limit);
454 self
455 }
456
457 pub fn no_limit(mut self) -> Self {
459 self.limit = None;
460 self
461 }
462
463 pub fn sort_by(
465 mut self,
466 sort: SortOrder,
467 ) -> Self {
468 self.sort_by = sort;
469 self
470 }
471
472 pub fn include_trashed(mut self) -> Self {
474 self.include_trashed = true;
475 self
476 }
477
478 pub fn include_archived(mut self) -> Self {
480 self.include_archived = true;
481 self
482 }
483
484 pub fn include_all(mut self) -> Self {
486 self.include_trashed = true;
487 self.include_archived = true;
488 self
489 }
490}
491
492pub struct BearDb {
494 db_path: DatabasePath,
495 _metadata: schema::BearDbMetadata,
496 normalizing_cte: String,
497}
498
499impl BearDb {
500 pub fn new() -> Result<Self, BearError> {
503 let home_dir = dirs::home_dir().ok_or(BearError::NoHomeDirectory)?;
504
505 let db_path = home_dir.join(
506 "Library/Group Containers/9K33E3U3T4.net.shinyfrog.bear/Application Data/database.sqlite",
507 );
508
509 Self::new_with_path(DatabasePath::RealPath(db_path))
510 }
511
512 pub(crate) fn new_with_path(db_path: DatabasePath) -> Result<Self, BearError> {
515 let connection = db_path.open_connection()?;
517
518 let metadata = schema::discover_metadata(&connection)?;
520
521 let normalizing_cte = schema::generate_normalizing_cte(&metadata);
523
524 drop(connection);
526
527 Ok(BearDb {
528 db_path,
529 _metadata: metadata,
530 normalizing_cte,
531 })
532 }
533
534 fn with_connection<F, R>(
537 &self,
538 f: F,
539 ) -> Result<R, BearError>
540 where
541 F: FnOnce(&Queryable) -> Result<R, BearError>,
542 {
543 let connection = self.db_path.open_connection()?;
545
546 let queryable = Queryable::new(&connection, &self.normalizing_cte);
548
549 f(&queryable)
552 }
553
554 pub fn tags(&self) -> Result<TagsMap, BearError> {
556 self.with_connection(|queryable| {
557 let mut statement = queryable.prepare(
558 r"
559 SELECT
560 id,
561 name,
562 modified
563 FROM tags
564 ORDER BY name ASC",
565 )?;
566
567 let results: rusqlite::Result<Vec<Tag>> = statement.query_map([], tag_from_row)?.collect();
568
569 let tags = results?.into_iter().map(|tag| (tag.id(), tag)).collect();
570
571 Ok(TagsMap { tags })
572 })
573 }
574
575 pub fn note(
597 &self,
598 id: &NoteId,
599 ) -> Result<Option<Note>, BearError> {
600 self.with_connection(|queryable| {
601 let mut statement = queryable.prepare(
602 r"
603 SELECT
604 id,
605 core_db_id,
606 title,
607 content,
608 modified,
609 created,
610 is_pinned
611 FROM notes
612 WHERE id = ?",
613 )?;
614
615 let result = statement.query_row([id.as_str()], note_from_row);
616
617 match result {
618 Ok(note) => Ok(Some(note)),
619 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
620 Err(e) => Err(BearError::SqlError { source: e }),
621 }
622 })
623 }
624
625 pub fn notes(
645 &self,
646 query: NotesQuery,
647 ) -> Result<Vec<Note>, BearError> {
648 self.with_connection(|queryable| {
649 let mut where_clauses = Vec::new();
651 if !query.include_trashed {
652 where_clauses.push("is_trashed <> 1");
653 }
654 if !query.include_archived {
655 where_clauses.push("is_archived <> 1");
656 }
657
658 let where_clause = if where_clauses.is_empty() {
659 String::new()
660 } else {
661 format!("WHERE {}", where_clauses.join(" AND "))
662 };
663
664 let limit_clause = query
665 .limit
666 .map(|l| format!("LIMIT {}", l))
667 .unwrap_or_default();
668
669 let query = format!(
670 r"
671 SELECT
672 id,
673 core_db_id,
674 title,
675 content,
676 modified,
677 created,
678 is_pinned
679 FROM notes
680 {}
681 ORDER BY modified DESC
682 {}",
683 where_clause, limit_clause
684 );
685
686 let mut statement = queryable.prepare(&query)?;
687
688 let results: rusqlite::Result<Vec<Note>> = statement.query_map([], note_from_row)?.collect();
689
690 Ok(results?)
691 })
692 }
693
694 pub fn search(
726 &self,
727 search: SearchQuery,
728 ) -> Result<Vec<Note>, BearError> {
729 self.with_connection(|queryable| {
730 let mut search_conditions = Vec::new();
732
733 let like_operator = if search.case_sensitive {
734 "GLOB"
735 } else {
736 "LIKE"
737 };
738 let pattern = if search.case_sensitive {
739 format!("*{}*", search.query)
740 } else {
741 format!("%{}%", search.query)
742 };
743
744 if search.search_title {
745 search_conditions.push(format!("title {} ?", like_operator));
746 }
747 if search.search_content {
748 search_conditions.push(format!("content {} ?", like_operator));
749 }
750
751 if search_conditions.is_empty() {
753 return Ok(Vec::new());
754 }
755
756 let search_clause = format!("({})", search_conditions.join(" OR "));
757
758 let mut where_clauses = vec![search_clause];
760
761 if !search.include_trashed {
762 where_clauses.push("is_trashed <> 1".to_string());
763 }
764 if !search.include_archived {
765 where_clauses.push("is_archived <> 1".to_string());
766 }
767
768 let where_clause = format!("WHERE {}", where_clauses.join(" AND "));
769
770 let limit_clause = search
771 .limit
772 .map(|l| format!("LIMIT {}", l))
773 .unwrap_or_default();
774
775 let query_sql = format!(
776 r"
777 SELECT
778 id,
779 core_db_id,
780 title,
781 content,
782 modified,
783 created,
784 is_pinned
785 FROM notes
786 {}
787 ORDER BY {}
788 {}",
789 where_clause,
790 search.sort_by.to_sql(),
791 limit_clause
792 );
793
794 let mut statement = queryable.prepare(&query_sql)?;
795
796 let results: rusqlite::Result<Vec<Note>> = if search.search_title && search.search_content {
798 statement
800 .query_map([pattern.as_str(), pattern.as_str()], note_from_row)?
801 .collect()
802 } else {
803 statement
805 .query_map([pattern.as_str()], note_from_row)?
806 .collect()
807 };
808
809 Ok(results?)
810 })
811 }
812
813 pub fn note_links(
815 &self,
816 from: &NoteId,
817 ) -> Result<Vec<Note>, BearError> {
818 self.with_connection(|queryable| {
819 let mut statement = queryable.prepare(
820 r"
821 SELECT
822 n.id,
823 n.core_db_id,
824 n.title,
825 n.content,
826 n.modified,
827 n.created,
828 n.is_pinned
829 FROM notes as n
830 INNER JOIN note_links as nl ON nl.to_note_id = n.id
831 WHERE n.is_trashed <> 1 AND n.is_archived <> 1 AND nl.from_note_id = ?
832 ORDER BY n.modified DESC",
833 )?;
834
835 let results: rusqlite::Result<Vec<Note>> = statement
836 .query_map([from.as_str()], note_from_row)?
837 .collect();
838
839 Ok(results?)
840 })
841 }
842
843 pub fn note_tags(
845 &self,
846 from: &NoteId,
847 ) -> Result<HashSet<TagId>, BearError> {
848 self.with_connection(|queryable| {
849 let mut statement = queryable.prepare(
850 r"
851 SELECT
852 nt.tag_id
853 FROM note_tags nt
854 WHERE nt.note_id = ?",
855 )?;
856
857 let results: rusqlite::Result<HashSet<TagId>> = statement
858 .query_map([from.as_str()], |row| row.get("tag_id"))?
859 .collect();
860
861 Ok(results?)
862 })
863 }
864
865 pub fn query(
898 &self,
899 sql: &str,
900 ) -> Result<DataFrame, BearError> {
901 self.with_connection(|queryable| query_to_dataframe(queryable, sql))
902 }
903}
904
905pub struct Queryable<'a> {
908 conn: &'a Connection,
909 normalizing_cte: &'a str,
910}
911
912impl<'a> Queryable<'a> {
913 fn new(
915 conn: &'a Connection,
916 normalizing_cte: &'a str,
917 ) -> Self {
918 Self {
919 conn,
920 normalizing_cte,
921 }
922 }
923
924 #[cfg(test)]
928 pub(crate) fn new_for_test(
929 conn: &'a Connection,
930 normalizing_cte: &'a str,
931 ) -> Self {
932 Self::new(conn, normalizing_cte)
933 }
934
935 pub fn prepare(
938 &self,
939 user_sql: &str,
940 ) -> rusqlite::Result<rusqlite::Statement<'a>> {
941 let full_sql = format!("{}\n{}", self.normalizing_cte, user_sql);
942 self.conn.prepare(&full_sql)
943 }
944}
945
946#[cfg(test)]
947mod tests {
948 use super::*;
949
950 #[test]
952 fn test_beardb_with_inmemory() {
953 let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
955
956 let tags = db.tags().unwrap();
958 assert_eq!(tags.count(), 3); let notes = db.notes(NotesQuery::default()).unwrap();
961 assert_eq!(notes.len(), 4); let all_notes = db
965 .notes(NotesQuery::new().include_all().no_limit())
966 .unwrap();
967 assert_eq!(all_notes.len(), 5); let df = db
971 .query("SELECT id, title FROM notes WHERE is_trashed = 0")
972 .unwrap();
973 assert_eq!(df.height(), 4); assert_eq!(df.width(), 2); let df = db.query("SELECT COUNT(*) as count FROM notes").unwrap();
978 assert_eq!(df.height(), 1);
979 assert_eq!(df.width(), 1);
980
981 let series = df.column("count").unwrap();
983 let value = series.get(0).unwrap();
984 match value {
985 AnyValue::Int64(n) => assert_eq!(n, 5),
986 _ => panic!("Expected Int64, got: {:?}", value),
987 }
988
989 let df = db
991 .query(
992 r"
993 SELECT n.title, t.name as tag_name
994 FROM notes n
995 JOIN note_tags nt ON n.id = nt.note_id
996 JOIN tags t ON nt.tag_id = t.id
997 ",
998 )
999 .unwrap();
1000 assert_eq!(df.height(), 2); }
1002
1003 #[test]
1005 fn test_note_with_empty_title() {
1006 let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1007
1008 let notes = db
1010 .notes(NotesQuery::new().no_limit().include_all())
1011 .unwrap();
1012
1013 let note_with_empty_title = notes
1015 .iter()
1016 .find(|n| n.title().is_empty())
1017 .expect("Should have a note with empty title");
1018
1019 assert_eq!(note_with_empty_title.title(), "");
1021
1022 assert!(note_with_empty_title.content().is_some());
1024 assert_eq!(
1025 note_with_empty_title.content().unwrap(),
1026 "Content with empty title"
1027 );
1028 }
1029
1030 #[test]
1032 fn test_note_with_null_content() {
1033 let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1034
1035 let notes = db
1036 .notes(NotesQuery::new().no_limit().include_all())
1037 .unwrap();
1038
1039 let note_with_null_content = notes
1041 .iter()
1042 .find(|n| n.content().is_none())
1043 .expect("Should have a note with NULL content");
1044
1045 assert_eq!(note_with_null_content.title(), "Empty Note");
1047 assert!(note_with_null_content.content().is_none());
1048 }
1049
1050 #[test]
1052 fn test_all_notes_have_unique_id() {
1053 let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1054
1055 let notes = db
1056 .notes(NotesQuery::new().no_limit().include_all())
1057 .unwrap();
1058
1059 for note in notes {
1061 let uuid = note.id();
1062 assert!(!uuid.as_str().is_empty(), "unique_id should never be empty");
1063 }
1064 }
1065
1066 #[test]
1068 fn test_tag_with_null_modified() {
1069 let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1070
1071 let tags = db.tags().unwrap();
1072
1073 let unmodified_tag = tags
1075 .iter()
1076 .find(|t| t.modified().is_none())
1077 .expect("Should have a tag with NULL modified date");
1078
1079 assert_eq!(unmodified_tag.name(), Some("unmodified-tag"));
1081 assert!(unmodified_tag.modified().is_none());
1082 }
1083
1084 #[test]
1086 fn test_tags_count_includes_null_modified() {
1087 let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1088
1089 let tags = db.tags().unwrap();
1090
1091 assert_eq!(tags.count(), 3);
1093 }
1094
1095 #[test]
1097 fn test_query_with_empty_title() {
1098 let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1099
1100 let df = db
1102 .query("SELECT id, title, content FROM notes WHERE title = ''")
1103 .unwrap();
1104
1105 assert_eq!(df.height(), 1); }
1107
1108 #[test]
1110 fn test_query_with_null_content() {
1111 let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1112
1113 let df = db
1115 .query("SELECT id, title, content FROM notes WHERE content IS NULL")
1116 .unwrap();
1117
1118 assert_eq!(df.height(), 1); }
1120
1121 #[test]
1123 fn test_note_tags_names_handles_null() {
1124 let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1125
1126 let tags = db.tags().unwrap();
1127
1128 let all_tag_ids: HashSet<_> = tags.iter().map(|t| t.id()).collect();
1130
1131 let names = tags.names(&all_tag_ids);
1133
1134 assert_eq!(names.len(), 3);
1136 assert!(names.contains("work"));
1137 assert!(names.contains("personal"));
1138 assert!(names.contains("unmodified-tag"));
1139 }
1140
1141 #[test]
1143 fn test_all_notes_have_valid_id() {
1144 let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1145
1146 let notes = db
1147 .notes(NotesQuery::new().no_limit().include_all())
1148 .unwrap();
1149
1150 for note in notes {
1152 let _id = note.id(); let _created = note.created();
1156 let _modified = note.modified();
1157
1158 let _is_pinned = note.is_pinned();
1160 }
1161 }
1162
1163 #[test]
1165 fn test_all_tags_have_valid_id() {
1166 let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1167
1168 let tags = db.tags().unwrap();
1169
1170 for tag in tags.iter() {
1172 let _id = tag.id(); }
1174 }
1175
1176 #[test]
1178 fn test_note_links_with_null_safe_notes() {
1179 let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1180
1181 let notes = db.notes(NotesQuery::new().limit(1)).unwrap();
1183 let first_note = ¬es[0];
1184
1185 let linked_notes = db.note_links(first_note.id()).unwrap();
1187
1188 for linked_note in linked_notes {
1190 let _id = linked_note.id();
1191 let _title = linked_note.title();
1192 let _content = linked_note.content(); }
1194 }
1195
1196 #[test]
1198 fn test_get_note_by_id_existing() {
1199 let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1200
1201 let notes = db.notes(NotesQuery::new().limit(1)).unwrap();
1203 let expected_note = ¬es[0];
1204 let note_id = expected_note.id();
1205
1206 let found_note = db.note(note_id).unwrap();
1208
1209 assert!(found_note.is_some());
1210 let found_note = found_note.unwrap();
1211
1212 assert_eq!(found_note.id(), expected_note.id());
1214 assert_eq!(found_note.title(), expected_note.title());
1215 assert_eq!(found_note.content(), expected_note.content());
1216 }
1217
1218 #[test]
1220 fn test_get_note_by_id_not_found() {
1221 let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1222
1223 let note_id = NoteId::new("nonexistent-uuid".to_string());
1225 let result = db.note(¬e_id).unwrap();
1226
1227 assert!(result.is_none());
1228 }
1229
1230 #[test]
1232 fn test_get_note_by_id_with_null_content() {
1233 let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1234
1235 let notes = db
1237 .notes(NotesQuery::new().no_limit().include_all())
1238 .unwrap();
1239 let null_content_note = notes.iter().find(|n| n.content().is_none()).unwrap();
1240 let note_id = null_content_note.id();
1241
1242 let found_note = db.note(note_id).unwrap();
1244
1245 assert!(found_note.is_some());
1246 let found_note = found_note.unwrap();
1247
1248 assert_eq!(found_note.title(), "Empty Note");
1250 assert!(found_note.content().is_none());
1251 }
1252
1253 #[test]
1255 fn test_note_method() {
1256 let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1257
1258 let notes = db.notes(NotesQuery::new().limit(1)).unwrap();
1260 let expected_note = ¬es[0];
1261 let note_id = expected_note.id();
1262
1263 let found_note = db.note(note_id).unwrap();
1265
1266 assert!(found_note.is_some());
1267 let found_note = found_note.unwrap();
1268
1269 assert_eq!(found_note.id(), expected_note.id());
1271 assert_eq!(found_note.title(), expected_note.title());
1272 assert_eq!(found_note.content(), expected_note.content());
1273
1274 let nonexistent_id = NoteId::new("nonexistent-uuid".to_string());
1276 let result = db.note(&nonexistent_id).unwrap();
1277 assert!(result.is_none());
1278 }
1279
1280 #[test]
1282 fn test_search_basic() {
1283 let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1284
1285 let results = db.search(SearchQuery::new("first")).unwrap();
1287 assert_eq!(results.len(), 1);
1288 assert_eq!(results[0].title(), "First Note");
1289
1290 let results = db.search(SearchQuery::new("second")).unwrap();
1292 assert_eq!(results.len(), 1);
1293 assert_eq!(results[0].title(), "Second Note");
1294
1295 let results = db.search(SearchQuery::new("Content")).unwrap();
1297 assert!(results.len() >= 2);
1298 }
1299
1300 #[test]
1302 fn test_search_title_only() {
1303 let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1304
1305 let results = db.search(SearchQuery::new("Note").title_only()).unwrap();
1307
1308 assert_eq!(results.len(), 3);
1310
1311 for note in &results {
1312 assert!(note.title().contains("Note"));
1313 }
1314 }
1315
1316 #[test]
1318 fn test_search_content_only() {
1319 let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1320
1321 let results = db
1323 .search(SearchQuery::new("Content").content_only())
1324 .unwrap();
1325
1326 assert!(results.len() >= 2);
1328
1329 for note in &results {
1330 if let Some(content) = note.content() {
1331 assert!(content.contains("Content"));
1332 }
1333 }
1334 }
1335
1336 #[test]
1338 fn test_search_case_sensitive() {
1339 let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1340
1341 let results = db.search(SearchQuery::new("FIRST")).unwrap();
1343 assert_eq!(results.len(), 1);
1344
1345 let results = db
1347 .search(SearchQuery::new("FIRST").case_sensitive())
1348 .unwrap();
1349 assert_eq!(results.len(), 0);
1350
1351 let results = db
1353 .search(SearchQuery::new("First").case_sensitive())
1354 .unwrap();
1355 assert_eq!(results.len(), 1);
1356 }
1357
1358 #[test]
1360 fn test_search_with_limit() {
1361 let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1362
1363 let results = db.search(SearchQuery::new("note").limit(2)).unwrap();
1365 assert!(results.len() <= 2);
1366
1367 let results = db.search(SearchQuery::new("note").no_limit()).unwrap();
1369 assert!(results.len() >= 2);
1370 }
1371
1372 #[test]
1374 fn test_search_with_sorting() {
1375 let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1376
1377 let results = db
1379 .search(
1380 SearchQuery::new("Note")
1381 .title_only()
1382 .sort_by(SortOn::Title.asc()),
1383 )
1384 .unwrap();
1385
1386 assert!(results.len() >= 2);
1387
1388 for i in 0..results.len() - 1 {
1390 assert!(results[i].title() <= results[i + 1].title());
1391 }
1392
1393 let results = db
1395 .search(
1396 SearchQuery::new("Note")
1397 .title_only()
1398 .sort_by(SortOn::Title.desc()),
1399 )
1400 .unwrap();
1401
1402 assert!(results.len() >= 2);
1403
1404 for i in 0..results.len() - 1 {
1406 assert!(results[i].title() >= results[i + 1].title());
1407 }
1408 }
1409
1410 #[test]
1412 fn test_search_excludes_trashed_by_default() {
1413 let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1414
1415 let results = db.search(SearchQuery::new("Trashed")).unwrap();
1417 assert_eq!(results.len(), 0);
1418
1419 let results = db
1421 .search(SearchQuery::new("Trashed").include_trashed())
1422 .unwrap();
1423 assert_eq!(results.len(), 1);
1424 assert_eq!(results[0].title(), "Trashed Note");
1425 }
1426
1427 #[test]
1429 fn test_search_include_all() {
1430 let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1431
1432 let results = db.search(SearchQuery::new("Note").include_all()).unwrap();
1434
1435 let has_trashed = results.iter().any(|n| n.title() == "Trashed Note");
1437 assert!(has_trashed);
1438 }
1439
1440 #[test]
1442 fn test_search_no_results() {
1443 let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1444
1445 let results = db.search(SearchQuery::new("nonexistent_term_xyz")).unwrap();
1446 assert_eq!(results.len(), 0);
1447 }
1448
1449 #[test]
1451 fn test_search_with_null_content() {
1452 let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1453
1454 let results = db
1456 .search(SearchQuery::new("Empty Note").title_only())
1457 .unwrap();
1458 assert_eq!(results.len(), 1);
1459 assert_eq!(results[0].title(), "Empty Note");
1460 assert!(results[0].content().is_none());
1461 }
1462
1463 #[test]
1465 fn test_search_with_empty_title() {
1466 let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1467
1468 let results = db
1470 .search(SearchQuery::new("empty title").content_only())
1471 .unwrap();
1472 assert_eq!(results.len(), 1);
1473 assert_eq!(results[0].title(), "");
1474 }
1475
1476 #[test]
1478 fn test_search_complex_query() {
1479 let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1480
1481 let results = db
1483 .search(
1484 SearchQuery::new("Note")
1485 .title_only()
1486 .sort_by(SortOn::Title.asc())
1487 .limit(2)
1488 .include_all(),
1489 )
1490 .unwrap();
1491
1492 assert!(results.len() <= 2);
1493
1494 if results.len() == 2 {
1496 assert!(results[0].title() <= results[1].title());
1497 }
1498 }
1499
1500 #[test]
1502 fn test_search_query_builder_chaining() {
1503 let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1504
1505 let query = SearchQuery::new("note")
1507 .title_only()
1508 .case_sensitive()
1509 .limit(10)
1510 .sort_by(SortOn::Modified.desc())
1511 .include_archived();
1512
1513 let _results = db.search(query).unwrap();
1515 }
1516
1517 #[test]
1519 fn test_search_all_sort_orders() {
1520 let db = BearDb::new_with_path(DatabasePath::InMemory).unwrap();
1521
1522 let orders = vec![
1524 SortOn::Modified.desc(),
1525 SortOn::Modified.asc(),
1526 SortOn::Created.desc(),
1527 SortOn::Created.asc(),
1528 SortOn::Title.asc(),
1529 SortOn::Title.desc(),
1530 ];
1531
1532 for order in orders {
1533 let _results = db.search(SearchQuery::new("Note").sort_by(order)).unwrap();
1534 }
1535 }
1536}