1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum ProviderType {
25 Local,
26 PortalsCloud,
27 Remote,
28}
29
30pub fn get_default_workspace_id() -> String {
39 std::env::var("NAP_WORKSPACE_ID").unwrap_or_else(|_| "default".to_string())
40}
41
42pub 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
58pub 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 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 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#[async_trait::async_trait]
102pub trait Provider: Send + Sync {
103 fn provider_type(&self) -> ProviderType;
105
106 fn name(&self) -> &str;
108
109 async fn initialize(&self) -> Result<()>;
111
112 async fn ensure_ready(&self) -> Result<()>;
114
115 fn lore_url_base(&self) -> Result<String>;
117
118 fn workspace_id(&self) -> &str;
120
121 async fn health_check(&self) -> Result<bool>;
123
124 async fn status(&self) -> Result<ProviderStatus>;
126}
127
128#[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
139pub struct ProviderFactory {
141 nap_home: std::path::PathBuf,
142}
143
144impl ProviderFactory {
145 pub fn new(nap_home: &Path) -> Self {
147 Self {
148 nap_home: nap_home.to_path_buf(),
149 }
150 }
151
152 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 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
181pub struct ProviderManager {
183 nap_home: std::path::PathBuf,
184 active_provider: Option<Arc<dyn Provider>>,
185}
186
187impl ProviderManager {
188 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 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 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 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 pub fn set_active_provider(&mut self, provider: Arc<dyn Provider>) {
288 self.active_provider = Some(provider);
289 }
290
291 pub fn active_provider(&self) -> Option<&Arc<dyn Provider>> {
293 self.active_provider.as_ref()
294 }
295
296 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 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#[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 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 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 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 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 tracing::debug!("Local provider config validated (no additional fields required)");
394 }
395 ProviderType::PortalsCloud => {
396 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 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 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 let config_path = temp_dir.path().join("provider.toml");
582 assert!(config_path.exists());
583
584 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}