Skip to main content

nap_core/provider/
mod.rs

1// SPDX-FileCopyrightText: 2026 Digital Creations
2// SPDX-License-Identifier: MIT
3//! Provider architecture for Lore server backends
4//!
5//! This module provides the provider abstraction that allows NAP to work with
6//! different Lore server deployments (Local, Portals Cloud, Remote) while
7//! maintaining a consistent repository API.
8
9use anyhow::{Context, Result};
10use std::path::Path;
11use std::sync::Arc;
12
13pub mod local;
14pub mod portals_cloud;
15pub mod remote;
16
17use local::LocalProvider;
18use portals_cloud::PortalsCloudProvider;
19use remote::RemoteProvider;
20
21/// Provider type enumeration
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum ProviderType {
24    Local,
25    PortalsCloud,
26    Remote,
27}
28
29/// Get default workspace ID from environment or use default
30///
31/// The workspace_id identifies a workspace within a Lore server instance.
32/// It scopes repositories to a specific workspace, allowing multiple
33/// isolated workspaces on the same server.
34///
35/// Environment variable: NAP_WORKSPACE_ID
36/// Default: "default"
37pub fn get_default_workspace_id() -> String {
38    std::env::var("NAP_WORKSPACE_ID").unwrap_or_else(|_| "default".to_string())
39}
40
41/// Check if NAP debug mode is enabled
42///
43/// Debug mode provides verbose logging for troubleshooting and development.
44/// When enabled, additional debug messages are logged throughout the SDK.
45///
46/// Environment variable: NAP_DEBUG
47/// Values: "1", "true", "yes" (case-insensitive) to enable
48pub fn is_debug_enabled() -> bool {
49    if let Ok(debug_var) = std::env::var("NAP_DEBUG") {
50        let debug_lower = debug_var.to_lowercase();
51        debug_var == "1" || debug_lower == "true" || debug_lower == "yes"
52    } else {
53        false
54    }
55}
56
57impl ProviderType {
58    /// Parse provider type from string
59    pub fn parse_from_str(s: &str) -> Result<Self> {
60        match s.to_lowercase().as_str() {
61            "local" => Ok(ProviderType::Local),
62            "portals-cloud" | "portalscloud" => Ok(ProviderType::PortalsCloud),
63            "remote" => Ok(ProviderType::Remote),
64            _ => anyhow::bail!("Unknown provider type: {}", s),
65        }
66    }
67
68    /// Convert to string
69    pub fn as_str(&self) -> &str {
70        match self {
71            ProviderType::Local => "local",
72            ProviderType::PortalsCloud => "portals-cloud",
73            ProviderType::Remote => "remote",
74        }
75    }
76}
77
78/// Provider trait for Lore server backends
79#[async_trait::async_trait]
80pub trait Provider: Send + Sync {
81    /// Get provider type
82    fn provider_type(&self) -> ProviderType;
83
84    /// Get provider name
85    fn name(&self) -> &str;
86
87    /// Initialize the provider
88    async fn initialize(&self) -> Result<()>;
89
90    /// Ensure the provider is ready for use
91    async fn ensure_ready(&self) -> Result<()>;
92
93    /// Get the Lore server URL base
94    fn lore_url_base(&self) -> Result<String>;
95
96    /// Get the workspace ID
97    fn workspace_id(&self) -> &str;
98
99    /// Check if provider is healthy
100    async fn health_check(&self) -> Result<bool>;
101
102    /// Get provider status
103    async fn status(&self) -> Result<ProviderStatus>;
104}
105
106/// Provider status information
107#[derive(Debug, Clone)]
108pub struct ProviderStatus {
109    pub provider_type: ProviderType,
110    pub ready: bool,
111    pub healthy: bool,
112    pub url_base: String,
113    pub workspace_id: String,
114    pub message: String,
115}
116
117/// Provider factory for creating provider instances
118pub struct ProviderFactory {
119    nap_home: std::path::PathBuf,
120}
121
122impl ProviderFactory {
123    /// Create a new provider factory
124    pub fn new(nap_home: &Path) -> Self {
125        Self {
126            nap_home: nap_home.to_path_buf(),
127        }
128    }
129
130    /// Create a provider by type
131    pub fn create_provider(&self, provider_type: ProviderType) -> Result<Arc<dyn Provider>> {
132        match provider_type {
133            ProviderType::Local => {
134                let provider: LocalProvider = LocalProvider::new(&self.nap_home);
135                Ok(Arc::new(provider) as Arc<dyn Provider>)
136            }
137            ProviderType::PortalsCloud => {
138                let provider: PortalsCloudProvider = PortalsCloudProvider::new();
139                Ok(Arc::new(provider) as Arc<dyn Provider>)
140            }
141            ProviderType::Remote => {
142                anyhow::bail!("Remote provider requires configuration (URL, workspace)");
143            }
144        }
145    }
146
147    /// Create a remote provider with configuration
148    pub fn create_remote_provider(
149        &self,
150        url_base: &str,
151        workspace_id: &str,
152    ) -> Result<Arc<dyn Provider>> {
153        let provider: RemoteProvider = RemoteProvider::new(url_base, workspace_id);
154        Ok(Arc::new(provider) as Arc<dyn Provider>)
155    }
156}
157
158/// Provider manager for managing the active provider
159pub struct ProviderManager {
160    nap_home: std::path::PathBuf,
161    active_provider: Option<Arc<dyn Provider>>,
162}
163
164impl ProviderManager {
165    /// Create a new provider manager
166    pub fn new(nap_home: &Path) -> Self {
167        Self {
168            nap_home: nap_home.to_path_buf(),
169            active_provider: None,
170        }
171    }
172
173    /// Load configured provider from disk
174    pub fn load_configured_provider(&mut self) -> Result<Option<Arc<dyn Provider>>> {
175        let config_path = self.nap_home.join("provider.toml");
176
177        if !config_path.exists() {
178            tracing::debug!(
179                "No provider configuration found at {}",
180                config_path.display()
181            );
182            if is_debug_enabled() {
183                tracing::debug!(
184                    "NAP debug mode: Provider config path does not exist: {}",
185                    config_path.display()
186                );
187            }
188            return Ok(None);
189        }
190
191        let config_content = std::fs::read_to_string(&config_path).context(format!(
192            "Failed to read provider configuration from '{}'",
193            config_path.display()
194        ))?;
195
196        if is_debug_enabled() {
197            tracing::debug!(
198                "NAP debug mode: Loaded provider config from {}",
199                config_path.display()
200            );
201            // Limit config content logging to avoid performance issues with large configs
202            let config_preview = if config_content.len() > 500 {
203                format!(
204                    "{}... (truncated, {} total chars)",
205                    &config_content[..500],
206                    config_content.len()
207                )
208            } else {
209                config_content.clone()
210            };
211            tracing::debug!("NAP debug mode: Config content: {}", config_preview);
212        }
213
214        let config: ProviderConfig = toml::from_str(&config_content).context(format!(
215            "Failed to parse provider configuration from '{}'. \
216                 The file may be corrupted. Delete it and run 'nap init' to reconfigure.",
217            config_path.display()
218        ))?;
219
220        // Validate the configuration
221        config.validate().context(format!(
222            "Invalid provider configuration in '{}'",
223            config_path.display()
224        ))?;
225
226        let factory = ProviderFactory::new(&self.nap_home);
227
228        let provider = match config.provider_type.as_str() {
229            "local" => Some(factory.create_provider(ProviderType::Local)?),
230            "portals-cloud" => Some(factory.create_provider(ProviderType::PortalsCloud)?),
231            "remote" => {
232                let url_base = config
233                    .remote_url
234                    .context("Remote provider requires remote_url in provider.toml")?;
235                let workspace_id = config
236                    .workspace_id
237                    .context("Remote provider requires workspace_id in provider.toml")?;
238                Some(factory.create_remote_provider(&url_base, &workspace_id)?)
239            }
240            _ => unreachable!("validated above"),
241        };
242
243        if let Some(ref provider) = provider {
244            self.active_provider = Some(provider.clone());
245            if is_debug_enabled() {
246                tracing::debug!("NAP debug mode: Loaded provider: {}", provider.name());
247                tracing::debug!(
248                    "NAP debug mode: Provider type: {:?}",
249                    provider.provider_type()
250                );
251            }
252            tracing::info!(
253                provider = %provider.name(),
254                provider_type = %config.provider_type,
255                "Loaded provider configuration"
256            );
257        }
258
259        Ok(provider)
260    }
261
262    /// Set the active provider
263    pub fn set_active_provider(&mut self, provider: Arc<dyn Provider>) {
264        self.active_provider = Some(provider);
265    }
266
267    /// Get the active provider
268    pub fn active_provider(&self) -> Option<&Arc<dyn Provider>> {
269        self.active_provider.as_ref()
270    }
271
272    /// Save provider configuration to disk
273    pub fn save_provider_config(&self, provider: &dyn Provider) -> Result<()> {
274        let config = ProviderConfig {
275            provider_type: provider.provider_type().as_str().to_string(),
276            remote_url: provider.lore_url_base().ok(),
277            workspace_id: Some(provider.workspace_id().to_string()),
278        };
279
280        let config_content = toml::to_string_pretty(&config)
281            .context("Failed to serialize provider configuration")?;
282
283        let config_path = self.nap_home.join("provider.toml");
284        std::fs::write(&config_path, config_content)
285            .context("Failed to write provider configuration")?;
286
287        Ok(())
288    }
289
290    /// Ensure the active provider is ready
291    pub async fn ensure_provider_ready(&self) -> Result<()> {
292        if let Some(provider) = &self.active_provider {
293            provider.ensure_ready().await?;
294            Ok(())
295        } else {
296            anyhow::bail!("No active provider configured");
297        }
298    }
299}
300
301/// Provider configuration stored on disk
302#[derive(Debug, serde::Serialize, serde::Deserialize)]
303struct ProviderConfig {
304    provider_type: String,
305    remote_url: Option<String>,
306    workspace_id: Option<String>,
307}
308
309impl ProviderConfig {
310    /// Validate the provider configuration.
311    ///
312    /// Ensures the config is complete and consistent for the declared provider type.
313    /// Uses manual validation with descriptive error messages for better UX than schema validation.
314    fn validate(&self) -> Result<()> {
315        if is_debug_enabled() {
316            tracing::debug!(
317                "NAP debug mode: Validating provider config for type: {}",
318                self.provider_type
319            );
320        }
321        // Validate provider_type is known
322        let provider_type = ProviderType::parse_from_str(&self.provider_type).context(format!(
323            "Invalid provider_type '{}' in provider.toml. \
324                 Expected one of: 'local', 'portals-cloud', 'remote'. \
325                 Fix the file at the NAP home directory or run 'nap init' to reconfigure.",
326            self.provider_type
327        ))?;
328
329        // Validate type-specific required fields
330        match provider_type {
331            ProviderType::Remote => {
332                if self.remote_url.is_none() {
333                    anyhow::bail!(
334                        "Remote provider requires 'remote_url' in provider.toml. \
335                         Add remote_url = \"lore://host:port\" to the [provider] section, \
336                         or reconfigure with 'nap init'."
337                    );
338                }
339                if self.workspace_id.is_none() {
340                    anyhow::bail!(
341                        "Remote provider requires 'workspace_id' in provider.toml. \
342                         Add workspace_id = \"your-workspace\" to the [provider] section, \
343                         or reconfigure with 'nap init'."
344                    );
345                }
346                // Validate URL format
347                let url = self.remote_url.as_ref().unwrap();
348                if !url.starts_with("lore://") && !url.starts_with("lores://") {
349                    anyhow::bail!(
350                        "Invalid remote_url '{}' in provider.toml. \
351                         URL must start with 'lore://' or 'lores://'. \
352                         Example: lore://localhost:41337",
353                        url
354                    );
355                }
356            }
357            ProviderType::Local => {
358                // Local provider has no required config fields beyond provider_type
359                tracing::debug!("Local provider config validated (no additional fields required)");
360            }
361            ProviderType::PortalsCloud => {
362                // Cloud provider reads auth from environment variables
363                tracing::debug!("Portals Cloud provider config validated (auth from environment)");
364            }
365        }
366
367        if is_debug_enabled() {
368            tracing::debug!(
369                "NAP debug mode: Provider config validation successful for type: {}",
370                self.provider_type
371            );
372        }
373        Ok(())
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use tempfile::TempDir;
381
382    #[test]
383    fn test_provider_type_from_str() {
384        assert_eq!(
385            ProviderType::parse_from_str("local").unwrap(),
386            ProviderType::Local
387        );
388        assert_eq!(
389            ProviderType::parse_from_str("portals-cloud").unwrap(),
390            ProviderType::PortalsCloud
391        );
392        assert_eq!(
393            ProviderType::parse_from_str("portalscloud").unwrap(),
394            ProviderType::PortalsCloud
395        );
396        assert_eq!(
397            ProviderType::parse_from_str("remote").unwrap(),
398            ProviderType::Remote
399        );
400    }
401
402    #[test]
403    fn test_provider_type_as_str() {
404        assert_eq!(ProviderType::Local.as_str(), "local");
405        assert_eq!(ProviderType::PortalsCloud.as_str(), "portals-cloud");
406        assert_eq!(ProviderType::Remote.as_str(), "remote");
407    }
408
409    #[test]
410    fn test_provider_type_from_str_unknown() {
411        let result = ProviderType::parse_from_str("nonexistent");
412        assert!(result.is_err());
413        assert!(
414            result
415                .unwrap_err()
416                .to_string()
417                .contains("Unknown provider type")
418        );
419    }
420
421    #[test]
422    fn test_provider_config_validation_local() {
423        let config = ProviderConfig {
424            provider_type: "local".to_string(),
425            remote_url: None,
426            workspace_id: None,
427        };
428        assert!(config.validate().is_ok());
429    }
430
431    #[test]
432    fn test_provider_config_validation_portals_cloud() {
433        let config = ProviderConfig {
434            provider_type: "portals-cloud".to_string(),
435            remote_url: None,
436            workspace_id: None,
437        };
438        assert!(config.validate().is_ok());
439    }
440
441    #[test]
442    fn test_provider_config_validation_remote_missing_url() {
443        let config = ProviderConfig {
444            provider_type: "remote".to_string(),
445            remote_url: None,
446            workspace_id: Some("default".to_string()),
447        };
448        let result = config.validate();
449        assert!(result.is_err());
450        assert!(result.unwrap_err().to_string().contains("remote_url"));
451    }
452
453    #[test]
454    fn test_provider_config_validation_remote_missing_workspace() {
455        let config = ProviderConfig {
456            provider_type: "remote".to_string(),
457            remote_url: Some("lore://localhost:41337".to_string()),
458            workspace_id: None,
459        };
460        let result = config.validate();
461        assert!(result.is_err());
462        assert!(result.unwrap_err().to_string().contains("workspace_id"));
463    }
464
465    #[test]
466    fn test_provider_config_validation_remote_bad_url() {
467        let config = ProviderConfig {
468            provider_type: "remote".to_string(),
469            remote_url: Some("http://localhost:41337".to_string()),
470            workspace_id: Some("default".to_string()),
471        };
472        let result = config.validate();
473        assert!(result.is_err());
474        assert!(result.unwrap_err().to_string().contains("lore://"));
475    }
476
477    #[test]
478    fn test_provider_config_validation_remote_valid() {
479        let config = ProviderConfig {
480            provider_type: "remote".to_string(),
481            remote_url: Some("lore://localhost:41337".to_string()),
482            workspace_id: Some("default".to_string()),
483        };
484        assert!(config.validate().is_ok());
485    }
486
487    #[test]
488    fn test_provider_config_validation_unknown_type() {
489        let config = ProviderConfig {
490            provider_type: "nonexistent".to_string(),
491            remote_url: None,
492            workspace_id: None,
493        };
494        let result = config.validate();
495        assert!(result.is_err());
496        assert!(
497            result
498                .unwrap_err()
499                .to_string()
500                .contains("Invalid provider_type")
501        );
502    }
503
504    #[test]
505    fn test_provider_factory_creation() {
506        let temp_dir = TempDir::new().unwrap();
507        let factory = ProviderFactory::new(temp_dir.path());
508
509        let local = factory.create_provider(ProviderType::Local).unwrap();
510        assert_eq!(local.provider_type(), ProviderType::Local);
511
512        let cloud = factory.create_provider(ProviderType::PortalsCloud).unwrap();
513        assert_eq!(cloud.provider_type(), ProviderType::PortalsCloud);
514
515        // Remote without config should fail
516        let remote = factory.create_provider(ProviderType::Remote);
517        assert!(remote.is_err());
518    }
519
520    #[test]
521    fn test_provider_factory_remote_with_config() {
522        let temp_dir = TempDir::new().unwrap();
523        let factory = ProviderFactory::new(temp_dir.path());
524
525        let remote = factory
526            .create_remote_provider("lore://localhost:41337", "test-ws")
527            .unwrap();
528        assert_eq!(remote.provider_type(), ProviderType::Remote);
529        assert_eq!(remote.workspace_id(), "test-ws");
530    }
531
532    #[test]
533    fn test_provider_manager_save_and_load() {
534        let temp_dir = TempDir::new().unwrap();
535
536        // Create and save a provider config
537        let mut manager = ProviderManager::new(temp_dir.path());
538        let factory = ProviderFactory::new(temp_dir.path());
539        let local_provider = factory.create_provider(ProviderType::Local).unwrap();
540
541        manager.set_active_provider(local_provider.clone());
542        manager
543            .save_provider_config(local_provider.as_ref())
544            .unwrap();
545
546        // Verify config file was written
547        let config_path = temp_dir.path().join("provider.toml");
548        assert!(config_path.exists());
549
550        // Load it back
551        let mut manager2 = ProviderManager::new(temp_dir.path());
552        let loaded = manager2.load_configured_provider().unwrap();
553        assert!(loaded.is_some());
554        assert_eq!(loaded.unwrap().provider_type(), ProviderType::Local);
555    }
556
557    #[test]
558    fn test_provider_config_roundtrip_serialization() {
559        let config = ProviderConfig {
560            provider_type: "remote".to_string(),
561            remote_url: Some("lore://host:41337".to_string()),
562            workspace_id: Some("my-workspace".to_string()),
563        };
564
565        let serialized = toml::to_string_pretty(&config).unwrap();
566        assert!(serialized.contains("remote"));
567        assert!(serialized.contains("lore://host:41337"));
568
569        let deserialized: ProviderConfig = toml::from_str(&serialized).unwrap();
570        assert_eq!(deserialized.provider_type, "remote");
571        assert_eq!(
572            deserialized.remote_url,
573            Some("lore://host:41337".to_string())
574        );
575        assert_eq!(deserialized.workspace_id, Some("my-workspace".to_string()));
576    }
577
578    #[test]
579    fn test_provider_manager_load_invalid_config_fails() {
580        let temp_dir = TempDir::new().unwrap();
581        let config_path = temp_dir.path().join("provider.toml");
582        std::fs::write(&config_path, "invalid-toml").unwrap();
583
584        let mut manager = ProviderManager::new(temp_dir.path());
585        let result = manager.load_configured_provider();
586        assert!(result.is_err());
587    }
588}