1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum ProviderType {
24 Local,
25 PortalsCloud,
26 Remote,
27}
28
29pub fn get_default_workspace_id() -> String {
38 std::env::var("NAP_WORKSPACE_ID").unwrap_or_else(|_| "default".to_string())
39}
40
41pub 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
57pub 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 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 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#[async_trait::async_trait]
101pub trait Provider: Send + Sync {
102 fn provider_type(&self) -> ProviderType;
104
105 fn name(&self) -> &str;
107
108 async fn initialize(&self) -> Result<()>;
110
111 async fn ensure_ready(&self) -> Result<()>;
113
114 fn lore_url_base(&self) -> Result<String>;
116
117 fn workspace_id(&self) -> &str;
119
120 async fn health_check(&self) -> Result<bool>;
122
123 async fn status(&self) -> Result<ProviderStatus>;
125}
126
127#[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
138pub struct ProviderFactory {
140 nap_home: std::path::PathBuf,
141}
142
143impl ProviderFactory {
144 pub fn new(nap_home: &Path) -> Self {
146 Self {
147 nap_home: nap_home.to_path_buf(),
148 }
149 }
150
151 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 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
179pub struct ProviderManager {
181 nap_home: std::path::PathBuf,
182 active_provider: Option<Arc<dyn Provider>>,
183}
184
185impl ProviderManager {
186 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 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 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 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 pub fn set_active_provider(&mut self, provider: Arc<dyn Provider>) {
285 self.active_provider = Some(provider);
286 }
287
288 pub fn active_provider(&self) -> Option<&Arc<dyn Provider>> {
290 self.active_provider.as_ref()
291 }
292
293 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 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#[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 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 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 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 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 tracing::debug!("Local provider config validated (no additional fields required)");
381 }
382 ProviderType::PortalsCloud => {
383 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 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 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 let config_path = temp_dir.path().join("provider.toml");
569 assert!(config_path.exists());
570
571 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}