1mod prompts;
2
3use anyhow::{Result, anyhow};
4use chrono::{DateTime, Utc};
5use collections::HashMap;
6use futures::FutureExt as _;
7use futures::future::Shared;
8use fuzzy::StringMatchCandidate;
9use gpui::{
10 App, AppContext, Context, Entity, EventEmitter, Global, ReadGlobal, SharedString, Task,
11};
12use heed::{
13 Database, RoTxn,
14 types::{SerdeBincode, SerdeJson, Str},
15};
16use parking_lot::RwLock;
17pub use prompts::*;
18use rope::Rope;
19use serde::{Deserialize, Serialize};
20use std::{
21 cmp::Reverse,
22 future::Future,
23 path::PathBuf,
24 sync::{Arc, atomic::AtomicBool},
25};
26use strum::{EnumIter, IntoEnumIterator as _};
27use text::LineEnding;
28use util::ResultExt;
29use uuid::Uuid;
30
31/// Init starts loading the PromptStore in the background and assigns
32/// a shared future to a global.
33pub fn init(cx: &mut App) {
34 let db_path = paths::prompts_dir().join("prompts-library-db.0.mdb");
35 let prompt_store_task = PromptStore::new(db_path, cx);
36 let prompt_store_entity_task = cx
37 .spawn(async move |cx| {
38 prompt_store_task
39 .await
40 .and_then(|prompt_store| cx.new(|_cx| prompt_store))
41 .map_err(Arc::new)
42 })
43 .shared();
44 cx.set_global(GlobalPromptStore(prompt_store_entity_task))
45}
46
47#[derive(Clone, Debug, Serialize, Deserialize)]
48pub struct PromptMetadata {
49 pub id: PromptId,
50 pub title: Option<SharedString>,
51 pub default: bool,
52 pub saved_at: DateTime<Utc>,
53}
54
55impl PromptMetadata {
56 fn builtin(builtin: BuiltInPrompt) -> Self {
57 Self {
58 id: PromptId::BuiltIn(builtin),
59 title: Some(builtin.title().into()),
60 default: false,
61 saved_at: DateTime::default(),
62 }
63 }
64}
65
66/// Built-in prompts that have default content and can be customized by users.
67#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, EnumIter)]
68pub enum BuiltInPrompt {
69 CommitMessage,
70}
71
72impl BuiltInPrompt {
73 pub fn title(&self) -> &'static str {
74 match self {
75 Self::CommitMessage => "Commit message",
76 }
77 }
78
79 /// Returns the default content for this built-in prompt.
80 pub fn default_content(&self) -> &'static str {
81 match self {
82 Self::CommitMessage => include_str!("../../git_ui/src/commit_message_prompt.txt"),
83 }
84 }
85}
86
87impl std::fmt::Display for BuiltInPrompt {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 match self {
90 Self::CommitMessage => write!(f, "Commit message"),
91 }
92 }
93}
94
95#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
96#[serde(tag = "kind")]
97pub enum PromptId {
98 User { uuid: UserPromptId },
99 BuiltIn(BuiltInPrompt),
100}
101
102impl PromptId {
103 pub fn new() -> PromptId {
104 UserPromptId::new().into()
105 }
106
107 pub fn as_user(&self) -> Option<UserPromptId> {
108 match self {
109 Self::User { uuid } => Some(*uuid),
110 Self::BuiltIn { .. } => None,
111 }
112 }
113
114 pub fn as_built_in(&self) -> Option<BuiltInPrompt> {
115 match self {
116 Self::User { .. } => None,
117 Self::BuiltIn(builtin) => Some(*builtin),
118 }
119 }
120
121 pub fn is_built_in(&self) -> bool {
122 matches!(self, Self::BuiltIn { .. })
123 }
124
125 pub fn can_edit(&self) -> bool {
126 match self {
127 Self::User { .. } => true,
128 Self::BuiltIn(builtin) => match builtin {
129 BuiltInPrompt::CommitMessage => true,
130 },
131 }
132 }
133}
134
135impl From<BuiltInPrompt> for PromptId {
136 fn from(builtin: BuiltInPrompt) -> Self {
137 PromptId::BuiltIn(builtin)
138 }
139}
140
141impl From<UserPromptId> for PromptId {
142 fn from(uuid: UserPromptId) -> Self {
143 PromptId::User { uuid }
144 }
145}
146
147#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
148#[serde(transparent)]
149pub struct UserPromptId(pub Uuid);
150
151impl UserPromptId {
152 pub fn new() -> UserPromptId {
153 UserPromptId(Uuid::new_v4())
154 }
155}
156
157impl From<Uuid> for UserPromptId {
158 fn from(uuid: Uuid) -> Self {
159 UserPromptId(uuid)
160 }
161}
162
163impl std::fmt::Display for PromptId {
164 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165 match self {
166 PromptId::User { uuid } => write!(f, "{}", uuid.0),
167 PromptId::BuiltIn(builtin) => write!(f, "{}", builtin),
168 }
169 }
170}
171
172pub struct PromptStore {
173 env: heed::Env,
174 metadata_cache: RwLock<MetadataCache>,
175 metadata: Database<SerdeJson<PromptId>, SerdeJson<PromptMetadata>>,
176 bodies: Database<SerdeJson<PromptId>, Str>,
177}
178
179pub struct PromptsUpdatedEvent;
180
181impl EventEmitter<PromptsUpdatedEvent> for PromptStore {}
182
183#[derive(Default)]
184struct MetadataCache {
185 metadata: Vec<PromptMetadata>,
186 metadata_by_id: HashMap<PromptId, PromptMetadata>,
187}
188
189impl MetadataCache {
190 fn from_db(
191 db: Database<SerdeJson<PromptId>, SerdeJson<PromptMetadata>>,
192 txn: &RoTxn,
193 ) -> Result<Self> {
194 let mut cache = MetadataCache::default();
195 for result in db.iter(txn)? {
196 // Fail-open: skip records that can't be decoded (e.g. from a different branch)
197 // rather than failing the entire prompt store initialization.
198 let Ok((prompt_id, metadata)) = result else {
199 log::warn!(
200 "Skipping unreadable prompt record in database: {:?}",
201 result.err()
202 );
203 continue;
204 };
205 cache.metadata.push(metadata.clone());
206 cache.metadata_by_id.insert(prompt_id, metadata);
207 }
208
209 // Insert all the built-in prompts that were not customized by the user
210 for builtin in BuiltInPrompt::iter() {
211 let builtin_id = PromptId::BuiltIn(builtin);
212 if !cache.metadata_by_id.contains_key(&builtin_id) {
213 let metadata = PromptMetadata::builtin(builtin);
214 cache.metadata.push(metadata.clone());
215 cache.metadata_by_id.insert(builtin_id, metadata);
216 }
217 }
218 cache.sort();
219 Ok(cache)
220 }
221
222 fn insert(&mut self, metadata: PromptMetadata) {
223 self.metadata_by_id.insert(metadata.id, metadata.clone());
224 if let Some(old_metadata) = self.metadata.iter_mut().find(|m| m.id == metadata.id) {
225 *old_metadata = metadata;
226 } else {
227 self.metadata.push(metadata);
228 }
229 self.sort();
230 }
231
232 fn remove(&mut self, id: PromptId) {
233 self.metadata.retain(|metadata| metadata.id != id);
234 self.metadata_by_id.remove(&id);
235 }
236
237 fn sort(&mut self) {
238 self.metadata.sort_unstable_by(|a, b| {
239 a.title
240 .cmp(&b.title)
241 .then_with(|| b.saved_at.cmp(&a.saved_at))
242 });
243 }
244}
245
246impl PromptStore {
247 pub fn global(cx: &App) -> impl Future<Output = Result<Entity<Self>>> + use<> {
248 let store = GlobalPromptStore::global(cx).0.clone();
249 async move { store.await.map_err(|err| anyhow!(err)) }
250 }
251
252 pub fn new(db_path: PathBuf, cx: &App) -> Task<Result<Self>> {
253 cx.background_spawn(async move {
254 std::fs::create_dir_all(&db_path)?;
255
256 let db_env = unsafe {
257 heed::EnvOpenOptions::new()
258 .map_size(1024 * 1024 * 1024) // 1GB
259 .max_dbs(4) // Metadata and bodies (possibly v1 of both as well)
260 .open(db_path)?
261 };
262
263 let mut txn = db_env.write_txn()?;
264 let metadata = db_env.create_database(&mut txn, Some("metadata.v2"))?;
265 let bodies = db_env.create_database(&mut txn, Some("bodies.v2"))?;
266 txn.commit()?;
267
268 Self::upgrade_dbs(&db_env, metadata, bodies).log_err();
269
270 let txn = db_env.read_txn()?;
271 let metadata_cache = MetadataCache::from_db(metadata, &txn)?;
272 txn.commit()?;
273
274 Ok(PromptStore {
275 env: db_env,
276 metadata_cache: RwLock::new(metadata_cache),
277 metadata,
278 bodies,
279 })
280 })
281 }
282
283 fn upgrade_dbs(
284 env: &heed::Env,
285 metadata_db: heed::Database<SerdeJson<PromptId>, SerdeJson<PromptMetadata>>,
286 bodies_db: heed::Database<SerdeJson<PromptId>, Str>,
287 ) -> Result<()> {
288 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Hash)]
289 pub struct PromptIdV1(Uuid);
290
291 #[derive(Clone, Debug, Serialize, Deserialize)]
292 pub struct PromptMetadataV1 {
293 pub id: PromptIdV1,
294 pub title: Option<SharedString>,
295 pub default: bool,
296 pub saved_at: DateTime<Utc>,
297 }
298
299 let mut txn = env.write_txn()?;
300 let Some(bodies_v1_db) = env
301 .open_database::<SerdeBincode<PromptIdV1>, SerdeBincode<String>>(
302 &txn,
303 Some("bodies"),
304 )?
305 else {
306 return Ok(());
307 };
308 let mut bodies_v1 = bodies_v1_db
309 .iter(&txn)?
310 .collect::<heed::Result<HashMap<_, _>>>()?;
311
312 let Some(metadata_v1_db) = env
313 .open_database::<SerdeBincode<PromptIdV1>, SerdeBincode<PromptMetadataV1>>(
314 &txn,
315 Some("metadata"),
316 )?
317 else {
318 return Ok(());
319 };
320 let metadata_v1 = metadata_v1_db
321 .iter(&txn)?
322 .collect::<heed::Result<HashMap<_, _>>>()?;
323
324 for (prompt_id_v1, metadata_v1) in metadata_v1 {
325 let prompt_id_v2 = UserPromptId(prompt_id_v1.0).into();
326 let Some(body_v1) = bodies_v1.remove(&prompt_id_v1) else {
327 continue;
328 };
329
330 if metadata_db
331 .get(&txn, &prompt_id_v2)?
332 .is_none_or(|metadata_v2| metadata_v1.saved_at > metadata_v2.saved_at)
333 {
334 metadata_db.put(
335 &mut txn,
336 &prompt_id_v2,
337 &PromptMetadata {
338 id: prompt_id_v2,
339 title: metadata_v1.title.clone(),
340 default: metadata_v1.default,
341 saved_at: metadata_v1.saved_at,
342 },
343 )?;
344 bodies_db.put(&mut txn, &prompt_id_v2, &body_v1)?;
345 }
346 }
347
348 txn.commit()?;
349
350 Ok(())
351 }
352
353 pub fn load(&self, id: PromptId, cx: &App) -> Task<Result<String>> {
354 let env = self.env.clone();
355 let bodies = self.bodies;
356 cx.background_spawn(async move {
357 let txn = env.read_txn()?;
358 let mut prompt: String = match bodies.get(&txn, &id)? {
359 Some(body) => body.into(),
360 None => {
361 if let Some(built_in) = id.as_built_in() {
362 built_in.default_content().into()
363 } else {
364 anyhow::bail!("prompt not found")
365 }
366 }
367 };
368 LineEnding::normalize(&mut prompt);
369 Ok(prompt)
370 })
371 }
372
373 pub fn all_prompt_metadata(&self) -> Vec<PromptMetadata> {
374 self.metadata_cache.read().metadata.clone()
375 }
376
377 pub fn default_prompt_metadata(&self) -> Vec<PromptMetadata> {
378 return self
379 .metadata_cache
380 .read()
381 .metadata
382 .iter()
383 .filter(|metadata| metadata.default)
384 .cloned()
385 .collect::<Vec<_>>();
386 }
387
388 pub fn delete(&self, id: PromptId, cx: &Context<Self>) -> Task<Result<()>> {
389 self.metadata_cache.write().remove(id);
390
391 let db_connection = self.env.clone();
392 let bodies = self.bodies;
393 let metadata = self.metadata;
394
395 let task = cx.background_spawn(async move {
396 let mut txn = db_connection.write_txn()?;
397
398 metadata.delete(&mut txn, &id)?;
399 bodies.delete(&mut txn, &id)?;
400
401 txn.commit()?;
402 anyhow::Ok(())
403 });
404
405 cx.spawn(async move |this, cx| {
406 task.await?;
407 this.update(cx, |_, cx| cx.emit(PromptsUpdatedEvent)).ok();
408 anyhow::Ok(())
409 })
410 }
411
412 pub fn metadata(&self, id: PromptId) -> Option<PromptMetadata> {
413 self.metadata_cache.read().metadata_by_id.get(&id).cloned()
414 }
415
416 pub fn first(&self) -> Option<PromptMetadata> {
417 self.metadata_cache.read().metadata.first().cloned()
418 }
419
420 pub fn id_for_title(&self, title: &str) -> Option<PromptId> {
421 let metadata_cache = self.metadata_cache.read();
422 let metadata = metadata_cache
423 .metadata
424 .iter()
425 .find(|metadata| metadata.title.as_ref().map(|title| &***title) == Some(title))?;
426 Some(metadata.id)
427 }
428
429 pub fn search(
430 &self,
431 query: String,
432 cancellation_flag: Arc<AtomicBool>,
433 cx: &App,
434 ) -> Task<Vec<PromptMetadata>> {
435 let cached_metadata = self.metadata_cache.read().metadata.clone();
436 let executor = cx.background_executor().clone();
437 cx.background_spawn(async move {
438 let mut matches = if query.is_empty() {
439 cached_metadata
440 } else {
441 let candidates = cached_metadata
442 .iter()
443 .enumerate()
444 .filter_map(|(ix, metadata)| {
445 Some(StringMatchCandidate::new(ix, metadata.title.as_ref()?))
446 })
447 .collect::<Vec<_>>();
448 let matches = fuzzy::match_strings(
449 &candidates,
450 &query,
451 false,
452 true,
453 100,
454 &cancellation_flag,
455 executor,
456 )
457 .await;
458 matches
459 .into_iter()
460 .map(|mat| cached_metadata[mat.candidate_id].clone())
461 .collect()
462 };
463 matches.sort_by_key(|metadata| Reverse(metadata.default));
464 matches
465 })
466 }
467
468 pub fn save(
469 &self,
470 id: PromptId,
471 title: Option<SharedString>,
472 default: bool,
473 body: Rope,
474 cx: &Context<Self>,
475 ) -> Task<Result<()>> {
476 if !id.can_edit() {
477 return Task::ready(Err(anyhow!("this prompt cannot be edited")));
478 }
479
480 let body = body.to_string();
481 let is_default_content = id
482 .as_built_in()
483 .is_some_and(|builtin| body.trim() == builtin.default_content().trim());
484
485 let metadata = if let Some(builtin) = id.as_built_in() {
486 PromptMetadata::builtin(builtin)
487 } else {
488 PromptMetadata {
489 id,
490 title,
491 default,
492 saved_at: Utc::now(),
493 }
494 };
495
496 self.metadata_cache.write().insert(metadata.clone());
497
498 let db_connection = self.env.clone();
499 let bodies = self.bodies;
500 let metadata_db = self.metadata;
501
502 let task = cx.background_spawn(async move {
503 let mut txn = db_connection.write_txn()?;
504
505 if is_default_content {
506 metadata_db.delete(&mut txn, &id)?;
507 bodies.delete(&mut txn, &id)?;
508 } else {
509 metadata_db.put(&mut txn, &id, &metadata)?;
510 bodies.put(&mut txn, &id, &body)?;
511 }
512
513 txn.commit()?;
514
515 anyhow::Ok(())
516 });
517
518 cx.spawn(async move |this, cx| {
519 task.await?;
520 this.update(cx, |_, cx| cx.emit(PromptsUpdatedEvent)).ok();
521 anyhow::Ok(())
522 })
523 }
524
525 pub fn save_metadata(
526 &self,
527 id: PromptId,
528 mut title: Option<SharedString>,
529 default: bool,
530 cx: &Context<Self>,
531 ) -> Task<Result<()>> {
532 let mut cache = self.metadata_cache.write();
533
534 if !id.can_edit() {
535 title = cache
536 .metadata_by_id
537 .get(&id)
538 .and_then(|metadata| metadata.title.clone());
539 }
540
541 let prompt_metadata = PromptMetadata {
542 id,
543 title,
544 default,
545 saved_at: Utc::now(),
546 };
547
548 cache.insert(prompt_metadata.clone());
549
550 let db_connection = self.env.clone();
551 let metadata = self.metadata;
552
553 let task = cx.background_spawn(async move {
554 let mut txn = db_connection.write_txn()?;
555 metadata.put(&mut txn, &id, &prompt_metadata)?;
556 txn.commit()?;
557
558 anyhow::Ok(())
559 });
560
561 cx.spawn(async move |this, cx| {
562 task.await?;
563 this.update(cx, |_, cx| cx.emit(PromptsUpdatedEvent)).ok();
564 anyhow::Ok(())
565 })
566 }
567}
568
569/// Wraps a shared future to a prompt store so it can be assigned as a context global.
570pub struct GlobalPromptStore(Shared<Task<Result<Entity<PromptStore>, Arc<anyhow::Error>>>>);
571
572impl Global for GlobalPromptStore {}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577 use gpui::TestAppContext;
578
579 #[gpui::test]
580 async fn test_built_in_prompt_load_save(cx: &mut TestAppContext) {
581 cx.executor().allow_parking();
582
583 let temp_dir = tempfile::tempdir().unwrap();
584 let db_path = temp_dir.path().join("prompts-db");
585
586 let store = cx.update(|cx| PromptStore::new(db_path, cx)).await.unwrap();
587 let store = cx.new(|_cx| store);
588
589 let commit_message_id = PromptId::BuiltIn(BuiltInPrompt::CommitMessage);
590
591 let loaded_content = store
592 .update(cx, |store, cx| store.load(commit_message_id, cx))
593 .await
594 .unwrap();
595
596 let mut expected_content = BuiltInPrompt::CommitMessage.default_content().to_string();
597 LineEnding::normalize(&mut expected_content);
598 assert_eq!(
599 loaded_content.trim(),
600 expected_content.trim(),
601 "Loading a built-in prompt not in DB should return default content"
602 );
603
604 let metadata = store.read_with(cx, |store, _| store.metadata(commit_message_id));
605 assert!(
606 metadata.is_some(),
607 "Built-in prompt should always have metadata"
608 );
609 assert!(
610 store.read_with(cx, |store, _| {
611 store
612 .metadata_cache
613 .read()
614 .metadata_by_id
615 .contains_key(&commit_message_id)
616 }),
617 "Built-in prompt should always be in cache"
618 );
619
620 let custom_content = "Custom commit message prompt";
621 store
622 .update(cx, |store, cx| {
623 store.save(
624 commit_message_id,
625 Some("Commit message".into()),
626 false,
627 Rope::from(custom_content),
628 cx,
629 )
630 })
631 .await
632 .unwrap();
633
634 let loaded_custom = store
635 .update(cx, |store, cx| store.load(commit_message_id, cx))
636 .await
637 .unwrap();
638 assert_eq!(
639 loaded_custom.trim(),
640 custom_content.trim(),
641 "Custom content should be loaded after saving"
642 );
643
644 assert!(
645 store
646 .read_with(cx, |store, _| store.metadata(commit_message_id))
647 .is_some(),
648 "Built-in prompt should have metadata after customization"
649 );
650
651 store
652 .update(cx, |store, cx| {
653 store.save(
654 commit_message_id,
655 Some("Commit message".into()),
656 false,
657 Rope::from(BuiltInPrompt::CommitMessage.default_content()),
658 cx,
659 )
660 })
661 .await
662 .unwrap();
663
664 let metadata_after_reset =
665 store.read_with(cx, |store, _| store.metadata(commit_message_id));
666 assert!(
667 metadata_after_reset.is_some(),
668 "Built-in prompt should still have metadata after reset"
669 );
670 assert_eq!(
671 metadata_after_reset
672 .as_ref()
673 .and_then(|m| m.title.as_ref().map(|t| t.as_ref())),
674 Some("Commit message"),
675 "Built-in prompt should have default title after reset"
676 );
677
678 let loaded_after_reset = store
679 .update(cx, |store, cx| store.load(commit_message_id, cx))
680 .await
681 .unwrap();
682 let mut expected_content_after_reset =
683 BuiltInPrompt::CommitMessage.default_content().to_string();
684 LineEnding::normalize(&mut expected_content_after_reset);
685 assert_eq!(
686 loaded_after_reset.trim(),
687 expected_content_after_reset.trim(),
688 "Content should be back to default after saving default content"
689 );
690 }
691
692 /// Test that the prompt store initializes successfully even when the database
693 /// contains records with incompatible/undecodable PromptId keys (e.g., from
694 /// a different branch that used a different serialization format).
695 ///
696 /// This is a regression test for the "fail-open" behavior: we should skip
697 /// bad records rather than failing the entire store initialization.
698 #[gpui::test]
699 async fn test_prompt_store_handles_incompatible_db_records(cx: &mut TestAppContext) {
700 cx.executor().allow_parking();
701
702 let temp_dir = tempfile::tempdir().unwrap();
703 let db_path = temp_dir.path().join("prompts-db-with-bad-records");
704 std::fs::create_dir_all(&db_path).unwrap();
705
706 // First, create the DB and write an incompatible record directly.
707 // We simulate a record written by a different branch that used
708 // `{"kind":"CommitMessage"}` instead of `{"kind":"BuiltIn", ...}`.
709 {
710 let db_env = unsafe {
711 heed::EnvOpenOptions::new()
712 .map_size(1024 * 1024 * 1024)
713 .max_dbs(4)
714 .open(&db_path)
715 .unwrap()
716 };
717
718 let mut txn = db_env.write_txn().unwrap();
719 // Create the metadata.v2 database with raw bytes so we can write
720 // an incompatible key format.
721 let metadata_db: Database<heed::types::Bytes, heed::types::Bytes> = db_env
722 .create_database(&mut txn, Some("metadata.v2"))
723 .unwrap();
724
725 // Write an incompatible PromptId key: `{"kind":"CommitMessage"}`
726 // This is the old/branch format that current code can't decode.
727 let bad_key = br#"{"kind":"CommitMessage"}"#;
728 let dummy_metadata = br#"{"id":{"kind":"CommitMessage"},"title":"Bad Record","default":false,"saved_at":"2024-01-01T00:00:00Z"}"#;
729 metadata_db.put(&mut txn, bad_key, dummy_metadata).unwrap();
730
731 // Also write a valid record to ensure we can still read good data.
732 let good_key = br#"{"kind":"User","uuid":"550e8400-e29b-41d4-a716-446655440000"}"#;
733 let good_metadata = br#"{"id":{"kind":"User","uuid":"550e8400-e29b-41d4-a716-446655440000"},"title":"Good Record","default":false,"saved_at":"2024-01-01T00:00:00Z"}"#;
734 metadata_db.put(&mut txn, good_key, good_metadata).unwrap();
735
736 txn.commit().unwrap();
737 }
738
739 // Now try to create a PromptStore from this DB.
740 // With fail-open behavior, this should succeed and skip the bad record.
741 // Without fail-open, this would return an error.
742 let store_result = cx.update(|cx| PromptStore::new(db_path, cx)).await;
743
744 assert!(
745 store_result.is_ok(),
746 "PromptStore should initialize successfully even with incompatible DB records. \
747 Got error: {:?}",
748 store_result.err()
749 );
750
751 let store = cx.new(|_cx| store_result.unwrap());
752
753 // Verify the good record was loaded.
754 let good_id = PromptId::User {
755 uuid: UserPromptId("550e8400-e29b-41d4-a716-446655440000".parse().unwrap()),
756 };
757 let metadata = store.read_with(cx, |store, _| store.metadata(good_id));
758 assert!(
759 metadata.is_some(),
760 "Valid records should still be loaded after skipping bad ones"
761 );
762 assert_eq!(
763 metadata
764 .as_ref()
765 .and_then(|m| m.title.as_ref().map(|t| t.as_ref())),
766 Some("Good Record"),
767 "Valid record should have correct title"
768 );
769 }
770}