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