Skip to main content

nap_core/repository_api/
mod.rs

1// SPDX-FileCopyrightText: 2026 Digital Creations
2// SPDX-License-Identifier: MIT
3//! Deployment-independent repository API
4//!
5//! This module provides the unified repository API that applications use.
6//! It abstracts away provider-specific details and ensures consistent behavior
7//! regardless of whether the repository is hosted on Local, Portals Cloud, or Remote.
8
9pub mod fallback;
10
11use anyhow::{Context, Result};
12use std::path::Path;
13use std::sync::Arc;
14use tracing::info;
15
16use super::provider::{Provider, ProviderManager, ProviderType};
17use super::vcs::VcsBackend;
18use super::vcs_lore::LoreBackend;
19
20/// Repository API for deployment-independent repository operations
21pub struct RepositoryApi {
22    nap_home: std::path::PathBuf,
23    provider_manager: ProviderManager,
24}
25
26impl RepositoryApi {
27    /// Create a new repository API
28    pub fn new(nap_home: &Path) -> Result<Self> {
29        // Ensure NAP home directory exists with proper error handling
30        std::fs::create_dir_all(nap_home).with_context(|| {
31            format!(
32                "Failed to create NAP home directory at '{}'. \
33                    Check permissions and disk space.",
34                nap_home.display()
35            )
36        })?;
37
38        let mut provider_manager = ProviderManager::new(nap_home);
39
40        // Try to load configured provider
41        provider_manager
42            .load_configured_provider()
43            .with_context(|| {
44                format!(
45                    "Failed to load provider configuration from '{}'",
46                    nap_home.join("provider.toml").display()
47                )
48            })?;
49
50        Ok(Self {
51            nap_home: nap_home.to_path_buf(),
52            provider_manager,
53        })
54    }
55
56    /// Initialize provider selection
57    pub async fn initialize_provider_selection(&mut self) -> Result<ProviderType> {
58        info!("Initializing provider selection");
59
60        // Check if provider is already configured
61        if let Some(provider) = self.provider_manager.active_provider() {
62            info!("Provider already configured: {}", provider.name());
63            return Ok(provider.provider_type());
64        }
65
66        // Prompt for provider selection (in real implementation, this would be interactive)
67        // For now, default to Local
68        let provider_type = ProviderType::Local;
69
70        let factory = super::provider::ProviderFactory::new(&self.nap_home);
71        let provider = factory.create_provider(provider_type)?;
72
73        self.provider_manager.set_active_provider(provider.clone());
74        self.provider_manager
75            .save_provider_config(provider.as_ref())?;
76
77        info!("Selected provider: {}", provider_type.as_str());
78        Ok(provider_type)
79    }
80
81    /// Ensure the active provider is ready
82    pub async fn ensure_provider_ready(&self) -> Result<()> {
83        if let Some(provider) = self.provider_manager.active_provider() {
84            provider.ensure_ready().await?;
85            Ok(())
86        } else {
87            anyhow::bail!(
88                "No active provider configured. Call initialize_provider_selection() first."
89            );
90        }
91    }
92
93    /// Create a new repository
94    pub async fn create_repository(
95        &self,
96        repo_id: &str,
97        workspace_id: Option<&str>,
98    ) -> Result<RepositoryHandle> {
99        info!("Creating repository: {}", repo_id);
100
101        let provider = self.provider_manager.active_provider().context(
102            "No active provider configured. Call initialize_provider_selection() first.",
103        )?;
104
105        // Ensure provider is ready
106        provider.ensure_ready().await.with_context(|| {
107            format!(
108                "Failed to ensure provider '{}' is ready for repository creation",
109                provider.name()
110            )
111        })?;
112
113        // Get Lore URL base and workspace ID
114        let lore_url_base = provider.lore_url_base().with_context(|| {
115            format!(
116                "Failed to get Lore URL base from provider '{}'",
117                provider.name()
118            )
119        })?;
120        let workspace = workspace_id.unwrap_or(provider.workspace_id());
121
122        // Create Lore backend using provider configuration
123        let lore_backend = LoreBackend::from_provider(&lore_url_base, workspace);
124
125        // Create repository using Lore backend
126        let workspace_root = &self.nap_home;
127        let repo_path = workspace_root.join(repo_id);
128
129        // Ensure parent directory exists
130        if let Some(parent) = repo_path.parent() {
131            std::fs::create_dir_all(parent).with_context(|| {
132                format!(
133                    "Failed to create parent directory for repository at '{}'",
134                    repo_path.display()
135                )
136            })?;
137        }
138
139        // Track initial state for potential rollback
140        let repo_existed_before = repo_path.exists();
141
142        match lore_backend.init(&repo_path) {
143            Ok(()) => {
144                info!("Repository created: {}", repo_id);
145                Ok(RepositoryHandle {
146                    id: repo_id.to_string(),
147                    workspace_id: workspace.to_string(),
148                    path: repo_path,
149                    lore_url_base,
150                })
151            }
152            Err(e) => {
153                // Rollback: remove partial repository directory if it was created during this call
154                if repo_path.exists() && !repo_existed_before {
155                    tracing::warn!(
156                        repo_id,
157                        path = %repo_path.display(),
158                        "Repository creation failed, cleaning up partial repository at '{}'",
159                        repo_path.display()
160                    );
161                    if let Err(cleanup_err) = std::fs::remove_dir_all(&repo_path) {
162                        tracing::error!(
163                            path = %repo_path.display(),
164                            error = %cleanup_err,
165                            "Failed to clean up partial repository directory after creation failure"
166                        );
167                    }
168                }
169                Err(anyhow::anyhow!(
170                    "Failed to create repository '{}': {}. \
171                     The operation has been rolled back and no partial state remains.",
172                    repo_id,
173                    e
174                ))
175            }
176        }
177    }
178
179    /// Open an existing repository
180    pub async fn open_repository(&self, repo_id: &str) -> Result<RepositoryHandle> {
181        info!("Opening repository: {}", repo_id);
182
183        let provider = self.provider_manager.active_provider().context(
184            "No active provider configured. Call initialize_provider_selection() first.",
185        )?;
186
187        let lore_url_base = provider.lore_url_base().with_context(|| {
188            format!(
189                "Failed to get Lore URL base from provider '{}'",
190                provider.name()
191            )
192        })?;
193        let workspace_id = provider.workspace_id();
194
195        let repo_path = self.nap_home.join(repo_id);
196
197        if !repo_path.exists() {
198            anyhow::bail!(
199                "Repository '{}' not found at '{}'. \
200                 Verify the repository ID and ensure it has been created.",
201                repo_id,
202                repo_path.display()
203            );
204        }
205
206        info!("Repository opened: {}", repo_id);
207
208        Ok(RepositoryHandle {
209            id: repo_id.to_string(),
210            workspace_id: workspace_id.to_string(),
211            path: repo_path,
212            lore_url_base,
213        })
214    }
215
216    /// Publish changes (semantic operation)
217    pub async fn publish(&self, repo_handle: &RepositoryHandle, message: &str) -> Result<String> {
218        info!("Publishing changes to repository: {}", repo_handle.id);
219
220        let provider = self.provider_manager.active_provider().context(
221            "No active provider configured. Call initialize_provider_selection() first.",
222        )?;
223
224        let workspace_id = provider.workspace_id();
225        let lore_backend = LoreBackend::from_provider(&repo_handle.lore_url_base, workspace_id);
226
227        // Commit changes
228        let commit_hash = lore_backend
229            .commit(&repo_handle.path, message, "nap")
230            .with_context(|| {
231                format!(
232                    "Failed to commit changes to repository '{}' at '{}'. \
233                     Check repository state and permissions.",
234                    repo_handle.id,
235                    repo_handle.path.display()
236                )
237            })?;
238
239        // Provider-specific publish behavior
240        match provider.provider_type() {
241            ProviderType::Local => {
242                // Local: just commit
243                info!("Published locally: {}", commit_hash);
244            }
245            ProviderType::PortalsCloud | ProviderType::Remote => {
246                // Cloud/Remote: commit and push
247                // If push fails, we need to rollback the commit to keep local state consistent
248                match lore_backend.push(&repo_handle.path, None, None) {
249                    Ok(_) => {
250                        info!("Published and synchronized: {}", commit_hash);
251                    }
252                    Err(push_err) => {
253                        // Rollback: revert the commit since push failed
254                        tracing::warn!(
255                            repo_id = %repo_handle.id,
256                            commit_hash = %commit_hash,
257                            "Push failed, rolling back commit to keep local state consistent"
258                        );
259                        let rollback_result = lore_backend.revert(&repo_handle.path, &commit_hash);
260                        if let Err(revert_err) = rollback_result {
261                            tracing::error!(
262                                repo_id = %repo_handle.id,
263                                commit_hash = %commit_hash,
264                                error = %revert_err,
265                                "Failed to rollback commit after push failure - repository may be in inconsistent state"
266                            );
267                            return Err(anyhow::anyhow!(
268                                "Failed to push changes to remote for repository '{}'. \
269                                 Attempted to rollback commit '{}' but failed. \
270                                 Repository may be in inconsistent state. \
271                                 Recovery options: \
272                                 1. Manually revert the commit: lore revert {} \
273                                 2. Reset to previous state: lore reset --hard HEAD~1 \
274                                 3. Check repository status: lore status \
275                                 Original push error: {}. Rollback error: {}",
276                                repo_handle.id,
277                                commit_hash,
278                                commit_hash,
279                                push_err,
280                                revert_err
281                            ));
282                        }
283                        return Err(anyhow::anyhow!(
284                            "Failed to push changes to remote for repository '{}'. \
285                             The commit has been successfully rolled back. \
286                             Original error: {}",
287                            repo_handle.id,
288                            push_err
289                        ));
290                    }
291                }
292            }
293        }
294
295        Ok(commit_hash)
296    }
297
298    /// Get repository history
299    pub async fn history(
300        &self,
301        repo_handle: &RepositoryHandle,
302        limit: usize,
303    ) -> Result<Vec<CommitInfo>> {
304        info!("Getting history for repository: {}", repo_handle.id);
305
306        let provider = self.provider_manager.active_provider().context(
307            "No active provider configured. Call initialize_provider_selection() first.",
308        )?;
309
310        let workspace_id = provider.workspace_id();
311        let lore_backend = LoreBackend::from_provider(&repo_handle.lore_url_base, workspace_id);
312
313        let commits = lore_backend
314            .log(&repo_handle.path, None, limit)
315            .with_context(|| {
316                format!(
317                    "Failed to get history for repository '{}' at '{}'. \
318                     Check repository state and Lore server connectivity.",
319                    repo_handle.id,
320                    repo_handle.path.display()
321                )
322            })?;
323
324        Ok(commits)
325    }
326
327    /// Create a branch
328    pub async fn create_branch(
329        &self,
330        repo_handle: &RepositoryHandle,
331        branch_name: &str,
332    ) -> Result<()> {
333        info!(
334            "Creating branch: {} in repository: {}",
335            branch_name, repo_handle.id
336        );
337
338        let provider = self.provider_manager.active_provider().context(
339            "No active provider configured. Call initialize_provider_selection() first.",
340        )?;
341
342        let workspace_id = provider.workspace_id();
343        let lore_backend = LoreBackend::from_provider(&repo_handle.lore_url_base, workspace_id);
344
345        lore_backend
346            .create_branch(&repo_handle.path, branch_name)
347            .with_context(|| {
348                format!(
349                    "Failed to create branch '{}' in repository '{}'. \
350                     Verify branch name is valid and repository is in a valid state.",
351                    branch_name, repo_handle.id
352                )
353            })?;
354
355        Ok(())
356    }
357
358    /// Switch to a branch
359    pub async fn switch_branch(
360        &self,
361        repo_handle: &RepositoryHandle,
362        branch_name: &str,
363    ) -> Result<()> {
364        info!(
365            "Switching to branch: {} in repository: {}",
366            branch_name, repo_handle.id
367        );
368
369        let provider = self.provider_manager.active_provider().context(
370            "No active provider configured. Call initialize_provider_selection() first.",
371        )?;
372
373        let workspace_id = provider.workspace_id();
374        let lore_backend = LoreBackend::from_provider(&repo_handle.lore_url_base, workspace_id);
375
376        lore_backend
377            .switch_branch(&repo_handle.path, branch_name)
378            .with_context(|| {
379                format!(
380                    "Failed to switch to branch '{}' in repository '{}'. \
381                     Verify branch exists and repository is in a clean state.",
382                    branch_name, repo_handle.id
383                )
384            })?;
385
386        Ok(())
387    }
388
389    /// List branches
390    pub async fn list_branches(&self, repo_handle: &RepositoryHandle) -> Result<Vec<String>> {
391        info!("Listing branches in repository: {}", repo_handle.id);
392
393        let provider = self.provider_manager.active_provider().context(
394            "No active provider configured. Call initialize_provider_selection() first.",
395        )?;
396
397        let workspace_id = provider.workspace_id();
398        let lore_backend = LoreBackend::from_provider(&repo_handle.lore_url_base, workspace_id);
399
400        let branches = lore_backend
401            .list_branches(&repo_handle.path)
402            .with_context(|| {
403                format!(
404                    "Failed to list branches in repository '{}'. \
405                     Check repository state and Lore server connectivity.",
406                    repo_handle.id
407                )
408            })?;
409
410        Ok(branches)
411    }
412
413    /// Synchronize repository (for cloud/remote providers)
414    pub async fn sync(&self, repo_handle: &RepositoryHandle) -> Result<()> {
415        info!("Synchronizing repository: {}", repo_handle.id);
416
417        let provider = self.provider_manager.active_provider().context(
418            "No active provider configured. Call initialize_provider_selection() first.",
419        )?;
420
421        match provider.provider_type() {
422            ProviderType::Local => {
423                info!("Synchronization not needed for local provider");
424                Ok(())
425            }
426            ProviderType::PortalsCloud | ProviderType::Remote => {
427                let workspace_id = provider.workspace_id();
428                let lore_backend =
429                    LoreBackend::from_provider(&repo_handle.lore_url_base, workspace_id);
430
431                // Record current head before pull for potential rollback
432                let head_before_pull = lore_backend.head_hash(&repo_handle.path).ok();
433
434                lore_backend
435                    .pull(&repo_handle.path, None, None)
436                    .with_context(|| {
437                        format!(
438                            "Failed to pull changes for repository '{}'. \
439                             Check network connectivity and remote server status.",
440                            repo_handle.id
441                        )
442                    })?;
443
444                match lore_backend.push(&repo_handle.path, None, None) {
445                    Ok(_) => {
446                        info!("Repository synchronized");
447                        Ok(())
448                    }
449                    Err(push_err) => {
450                        // Rollback: try to revert to state before pull if we recorded it
451                        if let Some(ref old_head) = head_before_pull {
452                            tracing::warn!(
453                                repo_id = %repo_handle.id,
454                                old_head = %old_head,
455                                "Push failed after pull, attempting to rollback to previous state"
456                            );
457                            let rollback_result = lore_backend.revert(&repo_handle.path, old_head);
458                            if let Err(revert_err) = rollback_result {
459                                tracing::error!(
460                                    repo_id = %repo_handle.id,
461                                    old_head = %old_head,
462                                    error = %revert_err,
463                                    "Failed to rollback sync operation - repository may be in inconsistent state"
464                                );
465                                return Err(anyhow::anyhow!(
466                                    "Failed to push changes for repository '{}'. \
467                                     Pull was attempted but push failed. \
468                                     Attempted to rollback to previous state '{}' but failed. \
469                                     Repository may be in inconsistent state. \
470                                     Recovery options: \
471                                     1. Manually reset to previous state: lore reset --hard {} \
472                                     2. Check repository status: lore status \
473                                     3. Force sync from remote: lore pull --force \
474                                     Original push error: {}. Rollback error: {}",
475                                    repo_handle.id,
476                                    old_head,
477                                    old_head,
478                                    push_err,
479                                    revert_err
480                                ));
481                            }
482                            return Err(anyhow::anyhow!(
483                                "Failed to push changes for repository '{}'. \
484                                 Pull was attempted but push failed. \
485                                 Successfully rolled back to previous state '{}'. \
486                                 Original error: {}",
487                                repo_handle.id,
488                                old_head,
489                                push_err
490                            ));
491                        }
492                        Err(anyhow::anyhow!(
493                            "Failed to push changes for repository '{}'. \
494                             Pull was attempted but push failed. \
495                             Could not rollback (no previous state recorded). \
496                             Original error: {}",
497                            repo_handle.id,
498                            push_err
499                        ))
500                    }
501                }
502            }
503        }
504    }
505
506    /// Delete a repository
507    pub async fn delete_repository(&self, repo_handle: &RepositoryHandle) -> Result<()> {
508        info!("Deleting repository: {}", repo_handle.id);
509
510        // Remove repository directory
511        std::fs::remove_dir_all(&repo_handle.path).with_context(|| {
512            format!(
513                "Failed to remove repository directory at '{}'. \
514                     Check file permissions and ensure no processes are using the repository.",
515                repo_handle.path.display()
516            )
517        })?;
518
519        info!("Repository deleted: {}", repo_handle.id);
520        Ok(())
521    }
522
523    /// Get the active provider, if any.
524    pub fn active_provider(&self) -> Option<&Arc<dyn Provider>> {
525        self.provider_manager.active_provider()
526    }
527
528    /// Get mutable access to the provider manager (for fallback, etc.).
529    pub fn provider_manager_mut(&mut self) -> &mut ProviderManager {
530        &mut self.provider_manager
531    }
532
533    /// Get the provider manager (read-only).
534    pub fn provider_manager(&self) -> &ProviderManager {
535        &self.provider_manager
536    }
537
538    /// Get provider status
539    pub async fn provider_status(&self) -> Result<super::provider::ProviderStatus> {
540        let provider = self
541            .provider_manager
542            .active_provider()
543            .context("No active provider configured")?;
544
545        provider.status().await
546    }
547}
548
549/// Handle to a repository
550#[derive(Debug, Clone)]
551pub struct RepositoryHandle {
552    pub id: String,
553    pub workspace_id: String,
554    pub path: std::path::PathBuf,
555    pub lore_url_base: String,
556}
557
558// Re-export CommitInfo from the vcs module (single source of truth).
559// repository_api methods return this type; callers should import from here
560// or from the vcs module directly.
561pub use super::vcs::CommitInfo;
562
563// Re-export fallback functionality
564pub use fallback::{FallbackHandler, FallbackResult, FallbackStrategy, RepositoryApiFallback};
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569    use tempfile::TempDir;
570
571    #[test]
572    fn test_repository_api_creation() {
573        let temp_dir = TempDir::new().unwrap();
574        let api = RepositoryApi::new(temp_dir.path()).unwrap();
575        assert_eq!(api.nap_home, temp_dir.path());
576    }
577
578    #[test]
579    fn test_repository_handle() {
580        let handle = RepositoryHandle {
581            id: "test-repo".to_string(),
582            workspace_id: "default".to_string(),
583            path: std::path::PathBuf::from("/tmp/test-repo"),
584            lore_url_base: "lore://localhost:41337".to_string(),
585        };
586
587        assert_eq!(handle.id, "test-repo");
588        assert_eq!(handle.workspace_id, "default");
589    }
590}