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