1use crate::cli::EmbeddingsCommands;
10use crate::config::resolve_db_path;
11use crate::embeddings::{
12 chunk_text, create_embedding_provider, detect_available_providers, get_embedding_settings,
13 is_embeddings_enabled, prepare_item_text, reset_embedding_settings, save_embedding_settings,
14 ChunkConfig, EmbeddingProviderType, EmbeddingSettings,
15};
16use tracing::{debug, info, warn};
17use crate::error::{Error, Result};
18use crate::storage::SqliteStorage;
19use serde::Serialize;
20use std::path::PathBuf;
21
22#[derive(Serialize)]
24struct StatusOutput {
25 enabled: bool,
26 configured_provider: Option<String>,
27 available_providers: Vec<ProviderStatus>,
28 active_provider: Option<ActiveProviderInfo>,
29 #[serde(skip_serializing_if = "Option::is_none")]
30 stats: Option<EmbeddingStatsOutput>,
31}
32
33#[derive(Serialize)]
34struct EmbeddingStatsOutput {
35 items_with_embeddings: usize,
36 items_without_embeddings: usize,
37 total_items: usize,
38}
39
40#[derive(Serialize)]
41struct ProviderStatus {
42 name: String,
43 available: bool,
44 model: Option<String>,
45 dimensions: Option<usize>,
46}
47
48#[derive(Serialize)]
49struct ActiveProviderInfo {
50 name: String,
51 model: String,
52 dimensions: usize,
53 max_chars: usize,
54}
55
56#[derive(Serialize)]
58struct TestOutput {
59 success: bool,
60 provider: String,
61 model: String,
62 dimensions: usize,
63 input_text: String,
64 embedding_sample: Vec<f32>,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 error: Option<String>,
67}
68
69#[derive(Serialize)]
71struct ConfigureOutput {
72 success: bool,
73 message: String,
74 settings: EmbeddingSettings,
75}
76
77#[derive(Serialize)]
79struct BackfillOutput {
80 processed: usize,
81 skipped: usize,
82 errors: usize,
83 provider: String,
84 model: String,
85}
86
87#[derive(Serialize)]
89struct UpgradeQualityOutput {
90 upgraded: usize,
91 skipped: usize,
92 errors: usize,
93 provider: String,
94 model: String,
95 total_eligible: usize,
96}
97
98pub fn execute(command: EmbeddingsCommands, db_path: Option<&PathBuf>, json: bool) -> Result<()> {
100 let rt = tokio::runtime::Runtime::new()
102 .map_err(|e| Error::Other(format!("Failed to create async runtime: {e}")))?;
103
104 rt.block_on(async { execute_async(command, db_path, json).await })
105}
106
107async fn execute_async(command: EmbeddingsCommands, db_path: Option<&PathBuf>, json: bool) -> Result<()> {
108 match command {
109 EmbeddingsCommands::Status => execute_status(db_path, json).await,
110 EmbeddingsCommands::Configure {
111 provider,
112 enable,
113 disable,
114 model,
115 endpoint,
116 token,
117 } => execute_configure(db_path, provider, enable, disable, model, endpoint, token, json).await,
118 EmbeddingsCommands::Backfill {
119 limit,
120 session,
121 force,
122 } => execute_backfill(db_path, limit, session, force, json).await,
123 EmbeddingsCommands::Test { text } => execute_test(&text, json).await,
124 EmbeddingsCommands::ProcessPending { limit, quiet } => {
125 execute_process_pending(db_path, limit, quiet).await
126 }
127 EmbeddingsCommands::UpgradeQuality { limit, session } => {
128 execute_upgrade_quality(db_path, limit, session, json).await
129 }
130 }
131}
132
133async fn execute_status(db_path: Option<&PathBuf>, json: bool) -> Result<()> {
135 let enabled = is_embeddings_enabled();
136 let settings = get_embedding_settings().unwrap_or_default();
137 let detection = detect_available_providers().await;
138
139 let stats = if let Some(path) = resolve_db_path(db_path.map(|p| p.as_path())) {
141 if path.exists() {
142 SqliteStorage::open(&path)
143 .ok()
144 .and_then(|storage| storage.count_embedding_status(None).ok())
145 .map(|s| EmbeddingStatsOutput {
146 items_with_embeddings: s.with_embeddings,
147 items_without_embeddings: s.without_embeddings,
148 total_items: s.with_embeddings + s.without_embeddings,
149 })
150 } else {
151 None
152 }
153 } else {
154 None
155 };
156
157 let active_provider = if enabled {
159 create_embedding_provider().await
160 } else {
161 None
162 };
163
164 let configured_provider = settings
165 .as_ref()
166 .and_then(|s| s.provider.as_ref())
167 .map(|p| p.to_string());
168
169 let mut providers = Vec::new();
171
172 let ollama_available = detection.available.contains(&"ollama".to_string());
174 providers.push(ProviderStatus {
175 name: "ollama".to_string(),
176 available: ollama_available,
177 model: if ollama_available {
178 Some(
179 settings
180 .as_ref()
181 .and_then(|s| s.OLLAMA_MODEL.clone())
182 .unwrap_or_else(|| "nomic-embed-text".to_string()),
183 )
184 } else {
185 None
186 },
187 dimensions: if ollama_available { Some(768) } else { None },
188 });
189
190 let hf_available = detection.available.contains(&"huggingface".to_string());
192 providers.push(ProviderStatus {
193 name: "huggingface".to_string(),
194 available: hf_available,
195 model: if hf_available {
196 Some(
197 settings
198 .as_ref()
199 .and_then(|s| s.HF_MODEL.clone())
200 .unwrap_or_else(|| "sentence-transformers/all-MiniLM-L6-v2".to_string()),
201 )
202 } else {
203 None
204 },
205 dimensions: if hf_available { Some(384) } else { None },
206 });
207
208 let active_info = active_provider.as_ref().map(|p| {
209 let info = p.info();
210 ActiveProviderInfo {
211 name: info.name,
212 model: info.model,
213 dimensions: info.dimensions,
214 max_chars: info.max_chars,
215 }
216 });
217
218 if json {
219 let output = StatusOutput {
220 enabled,
221 configured_provider,
222 available_providers: providers,
223 active_provider: active_info,
224 stats,
225 };
226 println!("{}", serde_json::to_string(&output)?);
227 } else {
228 println!("Embeddings Status");
229 println!("=================");
230 println!();
231 println!("Enabled: {}", if enabled { "yes" } else { "no" });
232 if let Some(ref p) = configured_provider {
233 println!("Configured Provider: {p}");
234 }
235 println!();
236
237 println!("Available Providers:");
238 for p in &providers {
239 let status = if p.available { "✓" } else { "✗" };
240 print!(" {status} {}", p.name);
241 if let Some(ref m) = p.model {
242 print!(" ({m})");
243 }
244 println!();
245 }
246 println!();
247
248 if let Some(ref active) = active_info {
249 println!("Active Provider:");
250 println!(" Name: {}", active.name);
251 println!(" Model: {}", active.model);
252 println!(" Dimensions: {}", active.dimensions);
253 println!(" Max Chars: {}", active.max_chars);
254 } else if enabled {
255 println!("No embedding provider available.");
256 println!();
257 println!("To enable embeddings:");
258 println!(" - Install Ollama: https://ollama.ai");
259 println!(" - Or set HF_TOKEN environment variable");
260 }
261
262 if let Some(ref s) = stats {
264 println!();
265 println!("Item Statistics:");
266 println!(" With embeddings: {}", s.items_with_embeddings);
267 println!(" Without embeddings: {}", s.items_without_embeddings);
268 println!(" Total items: {}", s.total_items);
269 if s.items_without_embeddings > 0 {
270 println!();
271 println!("Run 'sc embeddings backfill' to generate missing embeddings.");
272 }
273 }
274 }
275
276 Ok(())
277}
278
279#[allow(clippy::fn_params_excessive_bools)]
281async fn execute_configure(
282 db_path: Option<&PathBuf>,
283 provider: Option<String>,
284 enable: bool,
285 disable: bool,
286 model: Option<String>,
287 endpoint: Option<String>,
288 token: Option<String>,
289 json: bool,
290) -> Result<()> {
291 let mut settings = get_embedding_settings()
293 .unwrap_or_default()
294 .unwrap_or_default();
295
296 let mut changed = false;
297 let mut messages = Vec::new();
298
299 if enable && disable {
301 return Err(Error::InvalidArgument(
302 "Cannot specify both --enable and --disable".to_string(),
303 ));
304 }
305
306 if enable {
307 settings.enabled = Some(true);
308 messages.push("Embeddings enabled");
309 changed = true;
310 } else if disable {
311 settings.enabled = Some(false);
312 messages.push("Embeddings disabled");
313 changed = true;
314 }
315
316 if let Some(ref p) = provider {
318 let provider_type = match p.to_lowercase().as_str() {
319 "ollama" => EmbeddingProviderType::Ollama,
320 "huggingface" | "hf" => EmbeddingProviderType::Huggingface,
321 _ => {
322 return Err(Error::InvalidArgument(format!(
323 "Unknown provider: {p}. Valid options: ollama, huggingface"
324 )));
325 }
326 };
327 settings.provider = Some(provider_type);
328 messages.push("Provider configured");
329 changed = true;
330 }
331
332 if let Some(ref m) = model {
334 let provider_type = settings.provider.unwrap_or(EmbeddingProviderType::Ollama);
336 match provider_type {
337 EmbeddingProviderType::Ollama => {
338 settings.OLLAMA_MODEL = Some(m.clone());
339 }
340 EmbeddingProviderType::Huggingface => {
341 settings.HF_MODEL = Some(m.clone());
342 }
343 EmbeddingProviderType::Transformers => {
344 settings.TRANSFORMERS_MODEL = Some(m.clone());
345 }
346 EmbeddingProviderType::Model2vec => {
347 }
350 }
351 messages.push("Model configured");
352 changed = true;
353 }
354
355 if let Some(ref e) = endpoint {
357 let provider_type = settings.provider.unwrap_or(EmbeddingProviderType::Ollama);
358 match provider_type {
359 EmbeddingProviderType::Ollama => {
360 settings.OLLAMA_ENDPOINT = Some(e.clone());
361 }
362 EmbeddingProviderType::Huggingface => {
363 settings.HF_ENDPOINT = Some(e.clone());
364 }
365 _ => {}
366 }
367 messages.push("Endpoint configured");
368 changed = true;
369 }
370
371 if let Some(ref t) = token {
373 settings.HF_TOKEN = Some(t.clone());
374 messages.push("Token configured");
375 changed = true;
376 }
377
378 if !changed {
379 return execute_status(db_path, json).await;
381 }
382
383 save_embedding_settings(&settings)?;
385
386 let message = messages.join(", ");
387
388 if json {
389 let output = ConfigureOutput {
390 success: true,
391 message,
392 settings,
393 };
394 println!("{}", serde_json::to_string(&output)?);
395 } else {
396 println!("Configuration updated: {message}");
397 println!();
398 execute_status(db_path, false).await?;
399 }
400
401 Ok(())
402}
403
404async fn execute_backfill(
412 db_path: Option<&PathBuf>,
413 limit: Option<usize>,
414 session: Option<String>,
415 force: bool,
416 json: bool,
417) -> Result<()> {
418 let db_path = resolve_db_path(db_path.map(|p| p.as_path())).ok_or(Error::NotInitialized)?;
420
421 let provider = create_embedding_provider()
423 .await
424 .ok_or_else(|| Error::Embedding("No embedding provider available".to_string()))?;
425
426 let info = provider.info();
427 let provider_name = info.name.clone();
428 let model_name = info.model.clone();
429
430 let chunk_config = if provider_name.to_lowercase().contains("ollama") {
432 ChunkConfig::for_ollama()
433 } else {
434 ChunkConfig::for_minilm()
435 };
436
437 let mut storage = SqliteStorage::open(&db_path)?;
439
440 if force {
443 debug!("Force mode: resyncing phantom embedding statuses");
444 let reset_count = storage.resync_embedding_status()?;
445 if reset_count > 0 {
446 info!(reset_count, "Reset phantom embeddings (status was 'complete' but no data)");
447 if !json {
448 println!("Reset {} phantom embeddings (status was 'complete' but no data)", reset_count);
449 }
450 }
451 }
452
453 let items = storage.get_items_without_embeddings(session.as_deref(), Some(limit.unwrap_or(1000) as u32))?;
455 debug!(items_to_process = items.len(), "Backfill items queried");
456
457 if items.is_empty() {
458 if json {
459 let output = BackfillOutput {
460 processed: 0,
461 skipped: 0,
462 errors: 0,
463 provider: provider_name,
464 model: model_name,
465 };
466 println!("{}", serde_json::to_string(&output)?);
467 } else {
468 println!("No items to process.");
469 println!("All context items already have embeddings.");
470 }
471 return Ok(());
472 }
473
474 let total_items = items.len();
475 let mut processed = 0;
476 let mut skipped = 0;
477 let mut errors = 0;
478
479 if !json {
480 println!("Backfilling embeddings for {} items...", total_items);
481 println!("Provider: {} ({})", provider_name, model_name);
482 println!();
483 }
484
485 for item in items {
486 let text = prepare_item_text(&item.key, &item.value, Some(&item.category));
488
489 let chunks = chunk_text(&text, &chunk_config);
491
492 if chunks.is_empty() {
493 skipped += 1;
494 continue;
495 }
496
497 let mut chunk_errors = 0;
499 for (chunk_idx, chunk) in chunks.iter().enumerate() {
500 match provider.generate_embedding(&chunk.text).await {
501 Ok(embedding) => {
502 let chunk_id = format!("emb_{}_{}", item.id, chunk_idx);
504
505 if let Err(e) = storage.store_embedding_chunk(
507 &chunk_id,
508 &item.id,
509 chunk_idx as i32,
510 &chunk.text,
511 &embedding,
512 &provider_name,
513 &model_name,
514 ) {
515 if !json {
516 eprintln!(" Error storing chunk {}: {}", chunk_idx, e);
517 }
518 chunk_errors += 1;
519 }
520 }
521 Err(e) => {
522 if !json {
523 eprintln!(" Error generating embedding for {}: {}", item.key, e);
524 }
525 chunk_errors += 1;
526 }
527 }
528 }
529
530 if chunk_errors == 0 {
531 processed += 1;
532 if !json {
533 println!(" ✓ {} ({} chunks)", item.key, chunks.len());
534 }
535 } else if chunk_errors < chunks.len() {
536 processed += 1;
538 errors += chunk_errors;
539 if !json {
540 println!(" ⚠ {} ({}/{} chunks)", item.key, chunks.len() - chunk_errors, chunks.len());
541 }
542 } else {
543 errors += 1;
545 if !json {
546 println!(" ✗ {}", item.key);
547 }
548 }
549 }
550
551 if json {
552 let output = BackfillOutput {
553 processed,
554 skipped,
555 errors,
556 provider: provider_name,
557 model: model_name,
558 };
559 println!("{}", serde_json::to_string(&output)?);
560 } else {
561 println!();
562 println!("Complete!");
563 println!(" Processed: {}", processed);
564 println!(" Skipped: {}", skipped);
565 println!(" Errors: {}", errors);
566 }
567
568 Ok(())
569}
570
571async fn execute_test(text: &str, json: bool) -> Result<()> {
573 let provider = create_embedding_provider()
574 .await
575 .ok_or_else(|| Error::Embedding("No embedding provider available".to_string()))?;
576
577 let info = provider.info();
578
579 let result = provider.generate_embedding(text).await;
581
582 match result {
583 Ok(embedding) => {
584 let sample: Vec<f32> = embedding.iter().take(5).copied().collect();
585
586 if json {
587 let output = TestOutput {
588 success: true,
589 provider: info.name,
590 model: info.model,
591 dimensions: embedding.len(),
592 input_text: text.to_string(),
593 embedding_sample: sample,
594 error: None,
595 };
596 println!("{}", serde_json::to_string(&output)?);
597 } else {
598 println!("Embedding Test: SUCCESS");
599 println!();
600 println!("Provider: {}", info.name);
601 println!("Model: {}", info.model);
602 println!("Dimensions: {}", embedding.len());
603 println!("Input: \"{text}\"");
604 println!();
605 println!("Sample (first 5 values):");
606 for (i, v) in sample.iter().enumerate() {
607 println!(" [{i}] {v:.6}");
608 }
609 }
610 }
611 Err(e) => {
612 if json {
613 let output = TestOutput {
614 success: false,
615 provider: info.name,
616 model: info.model,
617 dimensions: 0,
618 input_text: text.to_string(),
619 embedding_sample: vec![],
620 error: Some(e.to_string()),
621 };
622 println!("{}", serde_json::to_string(&output)?);
623 } else {
624 println!("Embedding Test: FAILED");
625 println!();
626 println!("Provider: {}", info.name);
627 println!("Model: {}", info.model);
628 println!("Error: {e}");
629 }
630 return Err(e);
631 }
632 }
633
634 Ok(())
635}
636
637async fn execute_process_pending(
642 db_path: Option<&PathBuf>,
643 limit: usize,
644 quiet: bool,
645) -> Result<()> {
646 let db_path = resolve_db_path(db_path.map(|p| p.as_path())).ok_or(Error::NotInitialized)?;
648
649 if !db_path.exists() {
650 return Ok(()); }
652
653 if !is_embeddings_enabled() {
655 return Ok(());
656 }
657
658 let provider = match create_embedding_provider().await {
660 Some(p) => p,
661 None => return Ok(()), };
663
664 let info = provider.info();
665 let provider_name = info.name.clone();
666 let model_name = info.model.clone();
667
668 let chunk_config = if provider_name.to_lowercase().contains("ollama") {
670 ChunkConfig::for_ollama()
671 } else {
672 ChunkConfig::for_minilm()
673 };
674
675 let mut storage = SqliteStorage::open(&db_path)?;
677
678 let items = storage.get_items_without_embeddings(None, Some(limit as u32))?;
680
681 if items.is_empty() {
682 return Ok(());
683 }
684
685 let mut processed = 0;
686
687 for item in items {
688 let text = prepare_item_text(&item.key, &item.value, Some(&item.category));
690
691 let chunks = chunk_text(&text, &chunk_config);
693
694 if chunks.is_empty() {
695 continue;
696 }
697
698 let mut success = true;
700 for (chunk_idx, chunk) in chunks.iter().enumerate() {
701 match provider.generate_embedding(&chunk.text).await {
702 Ok(embedding) => {
703 let chunk_id = format!("emb_{}_{}", item.id, chunk_idx);
704 if storage
705 .store_embedding_chunk(
706 &chunk_id,
707 &item.id,
708 chunk_idx as i32,
709 &chunk.text,
710 &embedding,
711 &provider_name,
712 &model_name,
713 )
714 .is_err()
715 {
716 success = false;
717 break;
718 }
719 }
720 Err(_) => {
721 success = false;
722 break;
723 }
724 }
725 }
726
727 if success {
728 processed += 1;
729 if !quiet {
730 eprintln!("[bg] Embedded: {} ({} chunks)", item.key, chunks.len());
731 }
732 }
733 }
734
735 if !quiet && processed > 0 {
736 eprintln!("[bg] Processed {} pending embeddings", processed);
737 }
738
739 Ok(())
740}
741
742pub fn spawn_background_embedder() {
748 use std::fs;
749 use std::process::{Command, Stdio};
750
751 if !is_embeddings_enabled() {
753 debug!("Background embedder skipped: embeddings disabled");
754 return;
755 }
756
757 let exe = match std::env::current_exe() {
759 Ok(path) => path,
760 Err(e) => {
761 warn!(error = %e, "Background embedder: can't find executable");
762 return;
763 }
764 };
765
766 let log_file = directories::BaseDirs::new()
768 .map(|b| b.home_dir().join(".savecontext").join("logs"))
769 .and_then(|log_dir| {
770 fs::create_dir_all(&log_dir).ok()?;
771 fs::File::create(log_dir.join("embedder.log")).ok()
772 });
773
774 let stderr = match log_file {
775 Some(f) => Stdio::from(f),
776 None => Stdio::null(),
777 };
778
779 match Command::new(&exe)
781 .args(["embeddings", "process-pending", "--quiet"])
782 .stdin(Stdio::null())
783 .stdout(Stdio::null())
784 .stderr(stderr)
785 .spawn()
786 {
787 Ok(_) => debug!("Background embedder spawned"),
788 Err(e) => warn!(error = %e, exe = %exe.display(), "Background embedder spawn failed"),
789 }
790}
791
792#[allow(dead_code)]
794pub fn reset_to_defaults() -> Result<()> {
795 reset_embedding_settings()?;
796 println!("Embedding settings reset to defaults.");
797 Ok(())
798}
799
800async fn execute_upgrade_quality(
809 db_path: Option<&PathBuf>,
810 limit: Option<usize>,
811 session: Option<String>,
812 json: bool,
813) -> Result<()> {
814 let db_path = resolve_db_path(db_path.map(|p| p.as_path())).ok_or(Error::NotInitialized)?;
816
817 if !db_path.exists() {
818 return Err(Error::NotInitialized);
819 }
820
821 let provider = create_embedding_provider()
823 .await
824 .ok_or_else(|| Error::Embedding("No quality embedding provider available. Install Ollama or set HF_TOKEN.".to_string()))?;
825
826 let info = provider.info();
827 let provider_name = info.name.clone();
828 let model_name = info.model.clone();
829
830 let chunk_config = if provider_name.to_lowercase().contains("ollama") {
832 ChunkConfig::for_ollama()
833 } else {
834 ChunkConfig::for_minilm()
835 };
836
837 let mut storage = SqliteStorage::open(&db_path)?;
839
840 let items = storage.get_items_needing_quality_upgrade(
842 session.as_deref(),
843 limit.map(|l| l as u32),
844 )?;
845
846 let total_eligible = items.len();
847
848 if items.is_empty() {
849 if json {
850 let output = UpgradeQualityOutput {
851 upgraded: 0,
852 skipped: 0,
853 errors: 0,
854 provider: provider_name,
855 model: model_name,
856 total_eligible: 0,
857 };
858 println!("{}", serde_json::to_string(&output)?);
859 } else {
860 println!("No items need quality upgrade.");
861 println!("All items with fast embeddings already have quality embeddings.");
862 }
863 return Ok(());
864 }
865
866 if !json {
867 println!("Upgrading {} items to quality embeddings...", total_eligible);
868 println!("Provider: {} ({})", provider_name, model_name);
869 println!();
870 }
871
872 let mut upgraded = 0;
873 let mut skipped = 0;
874 let mut errors = 0;
875
876 for item in items {
877 let text = prepare_item_text(&item.key, &item.value, Some(&item.category));
879
880 let chunks = chunk_text(&text, &chunk_config);
882
883 if chunks.is_empty() {
884 skipped += 1;
885 if !json {
886 println!(" - {} (no content)", item.key);
887 }
888 continue;
889 }
890
891 let mut chunk_errors = 0;
893 for (chunk_idx, chunk) in chunks.iter().enumerate() {
894 match provider.generate_embedding(&chunk.text).await {
895 Ok(embedding) => {
896 let chunk_id = format!("emb_{}_{}", item.id, chunk_idx);
898
899 if let Err(e) = storage.store_embedding_chunk(
901 &chunk_id,
902 &item.id,
903 chunk_idx as i32,
904 &chunk.text,
905 &embedding,
906 &provider_name,
907 &model_name,
908 ) {
909 if !json {
910 eprintln!(" Error storing chunk {}: {}", chunk_idx, e);
911 }
912 chunk_errors += 1;
913 }
914 }
915 Err(e) => {
916 if !json {
917 eprintln!(" Error generating embedding for {}: {}", item.key, e);
918 }
919 chunk_errors += 1;
920 }
921 }
922 }
923
924 if chunk_errors == 0 {
925 upgraded += 1;
926 if !json {
927 println!(" ✓ {} ({} chunks)", item.key, chunks.len());
928 }
929 } else if chunk_errors < chunks.len() {
930 upgraded += 1;
932 errors += chunk_errors;
933 if !json {
934 println!(" ⚠ {} ({}/{} chunks)", item.key, chunks.len() - chunk_errors, chunks.len());
935 }
936 } else {
937 errors += 1;
939 if !json {
940 println!(" ✗ {}", item.key);
941 }
942 }
943 }
944
945 if json {
946 let output = UpgradeQualityOutput {
947 upgraded,
948 skipped,
949 errors,
950 provider: provider_name,
951 model: model_name,
952 total_eligible,
953 };
954 println!("{}", serde_json::to_string(&output)?);
955 } else {
956 println!();
957 println!("Quality upgrade complete!");
958 println!(" Upgraded: {}", upgraded);
959 println!(" Skipped: {}", skipped);
960 println!(" Errors: {}", errors);
961 println!();
962 println!("Items now have both fast (instant) and quality (accurate) embeddings.");
963 }
964
965 Ok(())
966}
967
968#[cfg(test)]
969mod tests {
970 use super::*;
971
972 #[test]
973 fn test_provider_status_serialization() {
974 let status = ProviderStatus {
975 name: "ollama".to_string(),
976 available: true,
977 model: Some("nomic-embed-text".to_string()),
978 dimensions: Some(768),
979 };
980 let json = serde_json::to_string(&status).unwrap();
981 assert!(json.contains("ollama"));
982 assert!(json.contains("768"));
983 }
984}