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
57impl ProviderType {
58 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 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#[async_trait::async_trait]
80pub trait Provider: Send + Sync {
81 fn provider_type(&self) -> ProviderType;
83
84 fn name(&self) -> &str;
86
87 async fn initialize(&self) -> Result<()>;
89
90 async fn ensure_ready(&self) -> Result<()>;
92
93 fn lore_url_base(&self) -> Result<String>;
95
96 fn workspace_id(&self) -> &str;
98
99 async fn health_check(&self) -> Result<bool>;
101
102 async fn status(&self) -> Result<ProviderStatus>;
104}
105
106#[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
117pub struct ProviderFactory {
119 nap_home: std::path::PathBuf,
120}
121
122impl ProviderFactory {
123 pub fn new(nap_home: &Path) -> Self {
125 Self {
126 nap_home: nap_home.to_path_buf(),
127 }
128 }
129
130 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 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
158pub struct ProviderManager {
160 nap_home: std::path::PathBuf,
161 active_provider: Option<Arc<dyn Provider>>,
162}
163
164impl ProviderManager {
165 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 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 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 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 pub fn set_active_provider(&mut self, provider: Arc<dyn Provider>) {
264 self.active_provider = Some(provider);
265 }
266
267 pub fn active_provider(&self) -> Option<&Arc<dyn Provider>> {
269 self.active_provider.as_ref()
270 }
271
272 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 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#[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 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 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 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 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 tracing::debug!("Local provider config validated (no additional fields required)");
360 }
361 ProviderType::PortalsCloud => {
362 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 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 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 let config_path = temp_dir.path().join("provider.toml");
548 assert!(config_path.exists());
549
550 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}