Skip to main content

nap_core/repository_api/
fallback.rs

1// SPDX-FileCopyrightText: 2026 Digital Creations
2// SPDX-License-Identifier: MIT
3//! Provider fallback UX
4//!
5//! Handles graceful fallback from cloud providers to local provider when
6//! cloud services are unavailable.
7
8use anyhow::{Context, Result};
9use tracing::{info, warn};
10
11use super::RepositoryApi;
12use crate::provider::{ProviderFactory, ProviderType};
13
14/// Fallback strategy for provider failures
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum FallbackStrategy {
17    /// No fallback - fail on provider unavailability
18    None,
19    /// Automatically fallback to local provider
20    Auto,
21    /// Prompt user before falling back
22    Prompt,
23}
24
25/// Fallback result
26#[derive(Debug, Clone)]
27pub enum FallbackResult {
28    /// No fallback needed
29    NotNeeded,
30    /// Fallback successful
31    Success {
32        original_provider: ProviderType,
33        fallback_provider: ProviderType,
34    },
35    /// Fallback failed
36    Failed {
37        original_provider: ProviderType,
38        error: String,
39    },
40    /// Fallback declined by user
41    Declined { original_provider: ProviderType },
42}
43
44/// Provider fallback handler
45pub struct FallbackHandler {
46    strategy: FallbackStrategy,
47    nap_home: std::path::PathBuf,
48}
49
50impl FallbackHandler {
51    /// Create a new fallback handler
52    pub fn new(nap_home: &std::path::Path) -> Self {
53        Self {
54            strategy: FallbackStrategy::Prompt,
55            nap_home: nap_home.to_path_buf(),
56        }
57    }
58
59    /// Set fallback strategy
60    pub fn with_strategy(mut self, strategy: FallbackStrategy) -> Self {
61        self.strategy = strategy;
62        self
63    }
64
65    /// Handle provider failure with fallback
66    pub async fn handle_provider_failure(
67        &self,
68        repository_api: &mut RepositoryApi,
69        original_provider: ProviderType,
70        error: &str,
71    ) -> Result<FallbackResult> {
72        warn!(
73            "Provider failure detected: {} - {}",
74            original_provider.as_str(),
75            error
76        );
77
78        // Only fallback from cloud providers to local
79        if !matches!(
80            original_provider,
81            ProviderType::PortalsCloud | ProviderType::Remote
82        ) {
83            return Ok(FallbackResult::Failed {
84                original_provider,
85                error: format!(
86                    "Cannot fallback from {} provider",
87                    original_provider.as_str()
88                ),
89            });
90        }
91
92        match self.strategy {
93            FallbackStrategy::None => Ok(FallbackResult::Failed {
94                original_provider,
95                error: "Fallback disabled".to_string(),
96            }),
97            FallbackStrategy::Auto => {
98                self.perform_fallback(repository_api, original_provider)
99                    .await
100            }
101            FallbackStrategy::Prompt => {
102                // In a real implementation, this would prompt the user
103                // For now, we'll simulate a prompt and default to yes
104                info!("Prompting user for fallback to local provider");
105                self.perform_fallback(repository_api, original_provider)
106                    .await
107            }
108        }
109    }
110
111    /// Perform the actual fallback to local provider
112    async fn perform_fallback(
113        &self,
114        repository_api: &mut RepositoryApi,
115        original_provider: ProviderType,
116    ) -> Result<FallbackResult> {
117        info!("Attempting fallback to local provider");
118
119        let factory = ProviderFactory::new(&self.nap_home);
120        let local_provider = factory
121            .create_provider(ProviderType::Local)
122            .context("Failed to create local provider for fallback")?;
123
124        // Initialize local provider
125        local_provider
126            .initialize()
127            .await
128            .context("Failed to initialize local provider during fallback")?;
129
130        // Ensure local provider is ready
131        local_provider
132            .ensure_ready()
133            .await
134            .context("Failed to ensure local provider ready during fallback")?;
135
136        // Update repository API with local provider
137        repository_api
138            .provider_manager_mut()
139            .set_active_provider(local_provider.clone());
140
141        // Save new provider configuration
142        repository_api
143            .provider_manager_mut()
144            .save_provider_config(local_provider.as_ref())?;
145
146        info!("Fallback to local provider successful");
147
148        Ok(FallbackResult::Success {
149            original_provider,
150            fallback_provider: ProviderType::Local,
151        })
152    }
153
154    /// Check if fallback should be offered based on error type
155    pub fn should_offer_fallback(&self, error: &str) -> bool {
156        // Offer fallback for network/connectivity errors
157        error.to_lowercase().contains("unavailable")
158            || error.to_lowercase().contains("timeout")
159            || error.to_lowercase().contains("connection")
160            || error.to_lowercase().contains("network")
161    }
162
163    /// Get user-friendly fallback message
164    pub fn fallback_message(&self, original_provider: ProviderType) -> String {
165        match original_provider {
166            ProviderType::PortalsCloud => "Portals Cloud is currently unavailable.\n\
167                 Start a local Lore server instead?\n\
168                 Changes will remain local until synchronization.\n\
169                 [Y/n]"
170                .to_string(),
171            ProviderType::Remote => "Remote Lore server is currently unavailable.\n\
172                 Start a local Lore server instead?\n\
173                 Changes will remain local until synchronization.\n\
174                 [Y/n]"
175                .to_string(),
176            ProviderType::Local => "Local provider failed. No fallback available.".to_string(),
177        }
178    }
179}
180
181/// Extension trait for RepositoryApi to add fallback support
182pub trait RepositoryApiFallback {
183    /// Perform operation with automatic fallback.
184    ///
185    /// Executes the closure; if it fails with a network-related error and
186    /// the current provider is cloud/remote, offers fallback to local.
187    /// On successful fallback, retries the operation once.
188    fn with_fallback<F, Fut, T>(
189        &mut self,
190        operation: F,
191    ) -> impl std::future::Future<Output = Result<T>> + Send
192    where
193        F: FnMut(&mut Self) -> Fut + Send,
194        Fut: std::future::Future<Output = Result<T>> + Send,
195        T: Send + 'static;
196
197    /// Check provider health and fallback if needed.
198    /// Returns `Ok(true)` if provider is healthy or fallback succeeded.
199    fn ensure_provider_with_fallback(
200        &mut self,
201    ) -> impl std::future::Future<Output = Result<bool>> + Send;
202}
203
204impl RepositoryApiFallback for RepositoryApi {
205    async fn with_fallback<F, Fut, T>(&mut self, mut operation: F) -> Result<T>
206    where
207        F: FnMut(&mut Self) -> Fut + Send,
208        Fut: std::future::Future<Output = Result<T>> + Send,
209        T: Send + 'static,
210    {
211        // Try the operation
212        let result = operation(self).await;
213
214        if let Err(e) = &result {
215            // Check if we should offer fallback
216            if let Some(provider) = self.active_provider() {
217                let handler = FallbackHandler::new(&self.nap_home);
218                let provider_type = provider.provider_type();
219
220                if handler.should_offer_fallback(&e.to_string()) {
221                    let fallback_result = handler
222                        .handle_provider_failure(self, provider_type, &e.to_string())
223                        .await?;
224
225                    match fallback_result {
226                        FallbackResult::Success { .. } => {
227                            // Retry operation with fallback provider
228                            operation(self).await
229                        }
230                        FallbackResult::Declined { .. } => result,
231                        FallbackResult::Failed { error, .. } => Err(anyhow::anyhow!(error)),
232                        FallbackResult::NotNeeded => result,
233                    }
234                } else {
235                    result
236                }
237            } else {
238                result
239            }
240        } else {
241            result
242        }
243    }
244
245    async fn ensure_provider_with_fallback(&mut self) -> Result<bool> {
246        let handler = FallbackHandler::new(&self.nap_home);
247
248        if let Some(provider) = self.active_provider() {
249            let provider_type = provider.provider_type();
250
251            // Check if provider is healthy
252            let is_healthy = provider.health_check().await.unwrap_or(false);
253
254            if is_healthy {
255                Ok(true)
256            } else {
257                // Provider is unhealthy, try fallback
258                let fallback_result = handler
259                    .handle_provider_failure(self, provider_type, "Provider health check failed")
260                    .await
261                    .map_err(|e| anyhow::anyhow!(e))?;
262
263                match fallback_result {
264                    FallbackResult::Success { .. } => Ok(true),
265                    FallbackResult::Declined { .. } => Ok(false),
266                    FallbackResult::Failed { error, .. } => Err(anyhow::anyhow!(error)),
267                    FallbackResult::NotNeeded => Ok(true),
268                }
269            }
270        } else {
271            Ok(false)
272        }
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use tempfile::TempDir;
280
281    #[test]
282    fn test_fallback_handler_creation() {
283        let temp_dir = TempDir::new().unwrap();
284        let handler = FallbackHandler::new(temp_dir.path());
285        assert_eq!(handler.strategy, FallbackStrategy::Prompt);
286    }
287
288    #[test]
289    fn test_fallback_strategy() {
290        let temp_dir = TempDir::new().unwrap();
291        let handler = FallbackHandler::new(temp_dir.path()).with_strategy(FallbackStrategy::Auto);
292        assert_eq!(handler.strategy, FallbackStrategy::Auto);
293    }
294
295    #[test]
296    fn test_should_offer_fallback() {
297        let temp_dir = TempDir::new().unwrap();
298        let handler = FallbackHandler::new(temp_dir.path());
299
300        assert!(handler.should_offer_fallback("Service unavailable"));
301        assert!(handler.should_offer_fallback("Connection timeout"));
302        assert!(handler.should_offer_fallback("Network error"));
303        assert!(!handler.should_offer_fallback("Permission denied"));
304    }
305
306    #[test]
307    fn test_fallback_message() {
308        let temp_dir = TempDir::new().unwrap();
309        let handler = FallbackHandler::new(temp_dir.path());
310
311        let message = handler.fallback_message(ProviderType::PortalsCloud);
312        assert!(message.contains("Portals Cloud is currently unavailable"));
313        assert!(message.contains("Start a local Lore server instead"));
314
315        let message = handler.fallback_message(ProviderType::Remote);
316        assert!(message.contains("Remote Lore server is currently unavailable"));
317
318        let message = handler.fallback_message(ProviderType::Local);
319        assert!(message.contains("No fallback available"));
320    }
321}