Skip to main content

nap_core/
repo_service.rs

1//! RepoService — the stable, high-level boundary between NAP consumer
2//! code and the Lore VCS adapter layer.
3//!
4//! ## Design principle
5//!
6//! **No call site outside the Lore adapter may shell out to `lore`, link
7//! against a Lore client library.**
8//!
9//! `RepoService` is the **only interface** through which the rest of
10//! nap-sdk touches version control.  It wraps:
11//!
12//! - A [`VcsBackend`] implementation (production: [`LoreBackend`])
13//! - A [`PermissionGate`]
14//! - A [`ContextDocsManager`]
15//!
16//! ## Lifecycle
17//!
18//! ```ignore
19//! // 1. Create a repository (server-side).
20//! let service = RepoService::new(backend, workspace_root)?;
21//! let repo = service.create_repository("my-workspace", "my-repo", RepoOptions::default())?;
22//!
23//! // 2. Get or open a workspace (local checkout).
24//! let ws = service.open_workspace()?;
25//!
26//! // 3. Use the workspace.
27//! service.write_file("characters/hero.yaml", "...", "alice")?;
28//! service.commit("add hero", "alice")?;
29//!
30//! ```
31
32use std::path::{Path, PathBuf};
33
34use crate::context_docs::ContextDocsManager;
35use crate::error::NapError;
36use crate::permission_gate::PermissionGate;
37use crate::vcs::ContextDocument;
38use crate::vcs::{CommitInfo, Repository, VcsBackend, Workspace, WorkspaceMode};
39use crate::vcs_lore::LoreBackend;
40
41// ---------------------------------------------------------------------------
42// RepoOptions
43// ---------------------------------------------------------------------------
44
45/// Options for creating a new repository.
46#[derive(Debug, Clone)]
47pub struct RepoOptions {
48    /// Human-readable description.
49    pub description: String,
50    /// Whether to make the repository public on the lore server.
51    pub public: bool,
52    /// Initial branch name.
53    pub default_branch: String,
54}
55
56impl Default for RepoOptions {
57    fn default() -> Self {
58        Self {
59            description: String::new(),
60            public: false,
61            default_branch: "main".to_string(),
62        }
63    }
64}
65
66// ---------------------------------------------------------------------------
67// RepoService
68// ---------------------------------------------------------------------------
69
70/// High-level interface for all VCS operations in NAP.
71///
72/// ## Thread safety
73///
74/// `RepoService` is `Send + Sync` and safe to share across threads.
75/// Internal state (e.g., the permission gate cache, context-doc graph)
76/// uses interior mutability.
77pub struct RepoService {
78    /// VCS backend (production: [`LoreBackend`]).
79    backend: Box<dyn VcsBackend>,
80    /// Workspace root path on disk.
81    workspace_root: PathBuf,
82    /// Permission gate.
83    pub permission_gate: PermissionGate,
84    /// Context-document manager.
85    pub context_docs: ContextDocsManager,
86}
87
88impl RepoService {
89    /// Create a new `RepoService` with the given backend and workspace path.
90    ///
91    /// The permission gate is loaded from `context/nap-gate.toml` if it
92    /// exists, otherwise a permissive gate is used.  Use
93    /// [`RepoService::with_gate`] for custom gate behaviour.
94    pub fn new(backend: Box<dyn VcsBackend>, workspace_root: &Path) -> Result<Self, NapError> {
95        let permission_gate = PermissionGate::load(workspace_root)?;
96        let context_docs = ContextDocsManager::new(workspace_root);
97
98        Ok(Self {
99            backend,
100            workspace_root: workspace_root.to_path_buf(),
101            permission_gate,
102            context_docs,
103        })
104    }
105
106    /// Create a `RepoService` with an explicit permission gate.
107    pub fn with_gate(
108        backend: Box<dyn VcsBackend>,
109        workspace_root: &Path,
110        permission_gate: PermissionGate,
111    ) -> Self {
112        let context_docs = ContextDocsManager::new(workspace_root);
113        Self {
114            backend,
115            workspace_root: workspace_root.to_path_buf(),
116            permission_gate,
117            context_docs,
118        }
119    }
120
121    /// Create a `RepoService` from environment variables (for the common
122    /// local-dev case).
123    pub fn from_env(workspace_root: &Path) -> Result<Self, NapError> {
124        let backend: Box<dyn VcsBackend> = Box::new(LoreBackend::from_env());
125        Self::new(backend, workspace_root)
126    }
127
128    /// The underlying VCS backend (for advanced use).
129    pub fn backend(&self) -> &dyn VcsBackend {
130        self.backend.as_ref()
131    }
132
133    /// Workspace root path.
134    pub fn workspace_root(&self) -> &Path {
135        &self.workspace_root
136    }
137
138    // ── Repository lifecycle ─────────────────────────────────────────
139
140    /// Create a repository on the lore server and clone it locally.
141    ///
142    /// This is the primary way to bootstrap a NAP workspace.
143    pub fn create_repository(
144        &self,
145        workspace_id: &str,
146        repo_id: &str,
147        _opts: RepoOptions,
148    ) -> Result<Repository, NapError> {
149        // The backend (LoreBackend) already contains the configured remote URL base
150        // from NAP_LORE_URL_BASE via its from_env() constructor. We derive the full
151        // repository URL from the backend's internal state rather than reading env vars
152        // directly here, ensuring a single source of truth for configuration.
153        let remote_url = match self.backend.remote_url_base() {
154            Ok(base) => format!("{}/{}", base.trim_end_matches('/'), repo_id),
155            Err(_) => {
156                // Fallback for backends that don't support remote_url_base
157                format!("lore://localhost:8700/{}", repo_id)
158            }
159        };
160
161        // Init creates the remote repo + clones locally.
162        self.backend.init(&self.workspace_root)?;
163
164        Ok(Repository {
165            id: repo_id.to_string(),
166            workspace_id: workspace_id.to_string(),
167            remote_url,
168        })
169    }
170
171    /// Open an existing local workspace (assumes `lore clone` already
172    /// happened, or the workspace directory already exists).
173    pub fn open_workspace(&self) -> Result<Workspace, NapError> {
174        if !self.workspace_root.exists() {
175            return Err(NapError::VcsError(format!(
176                "workspace does not exist: {:?}",
177                self.workspace_root
178            )));
179        }
180
181        let branch = self.backend.current_branch(&self.workspace_root)?;
182        let repo_id = self
183            .workspace_root
184            .file_name()
185            .and_then(|n| n.to_str())
186            .unwrap_or("unknown")
187            .to_string();
188
189        Ok(Workspace {
190            repository_id: repo_id,
191            path: self.workspace_root.to_string_lossy().to_string(),
192            branch,
193            mode: WorkspaceMode::Durable,
194        })
195    }
196
197    // ── Entity CRUD (with permission checks) ─────────────────────────
198
199    /// Write a file, checking permissions first.
200    pub fn write_file(&self, path: &str, content: &str, principal: &str) -> Result<(), NapError> {
201        self.permission_gate.check_write(path, principal)?;
202
203        let full_path = self.workspace_root.join(path);
204        if let Some(parent) = full_path.parent() {
205            std::fs::create_dir_all(parent).map_err(|e| {
206                NapError::Other(format!(
207                    "failed to create parent directory for '{}': {}",
208                    path, e
209                ))
210            })?;
211        }
212
213        std::fs::write(&full_path, content).map_err(NapError::Io)?;
214
215        Ok(())
216    }
217
218    /// Read a file, checking permissions first.
219    pub fn read_file(&self, path: &str, principal: &str) -> Result<String, NapError> {
220        self.permission_gate.check_read(path, principal)?;
221
222        let full_path = self.workspace_root.join(path);
223        std::fs::read_to_string(&full_path).map_err(NapError::Io)
224    }
225
226    // ── VCS operations ───────────────────────────────────────────────
227
228    /// Commit staged changes.
229    pub fn commit(&self, message: &str, author: &str) -> Result<String, NapError> {
230        self.backend.commit(&self.workspace_root, message, author)
231    }
232
233    /// Get commit history.
234    pub fn log(&self, file: Option<&str>, limit: usize) -> Result<Vec<CommitInfo>, NapError> {
235        self.backend.log(&self.workspace_root, file, limit)
236    }
237
238    /// Create a branch.
239    pub fn create_branch(&self, name: &str) -> Result<(), NapError> {
240        self.backend.create_branch(&self.workspace_root, name)
241    }
242
243    /// Switch to a branch.
244    pub fn switch_branch(&self, name: &str) -> Result<(), NapError> {
245        self.backend.switch_branch(&self.workspace_root, name)
246    }
247
248    /// Get current branch.
249    pub fn current_branch(&self) -> Result<String, NapError> {
250        self.backend.current_branch(&self.workspace_root)
251    }
252
253    /// List branches.
254    pub fn list_branches(&self) -> Result<Vec<String>, NapError> {
255        self.backend.list_branches(&self.workspace_root)
256    }
257
258    /// Push to the lore server.
259    pub fn push(&self, remote: Option<&str>, branch: Option<&str>) -> Result<(), NapError> {
260        self.backend.push(&self.workspace_root, remote, branch)
261    }
262
263    /// Pull from the lore server.
264    pub fn pull(&self, remote: Option<&str>, branch: Option<&str>) -> Result<(), NapError> {
265        self.backend.pull(&self.workspace_root, remote, branch)
266    }
267
268    // ── Context documents ────────────────────────────────────────────
269
270    /// Register a context document.  See [`ContextDocsManager::register`].
271    pub fn register_context_doc(
272        &self,
273        path: &str,
274        metadata: &[(&str, &str)],
275    ) -> Result<(), NapError> {
276        self.context_docs.register(path, metadata)
277    }
278
279    /// Add a dependency between context documents.
280    pub fn add_context_dep(&self, source: &str, target: &str) -> Result<(), NapError> {
281        self.context_docs.add_dependency(source, target)
282    }
283
284    /// Get all context documents.
285    pub fn all_context_docs(&self) -> Result<Vec<ContextDocument>, NapError> {
286        self.context_docs.all_documents()
287    }
288}
289
290// ---------------------------------------------------------------------------
291// Tests
292// ---------------------------------------------------------------------------
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use crate::test_utils::MockBackend;
298    use crate::vcs::AccessLevel;
299
300    #[test]
301    fn test_open_workspace_fails_on_missing() {
302        let backend = MockBackend::new();
303        let service = RepoService::with_gate(
304            Box::new(backend),
305            Path::new("/nonexistent-12345"),
306            PermissionGate::permissive(Path::new("/nonexistent-12345")),
307        );
308        let result = service.open_workspace();
309        assert!(result.is_err());
310        assert!(
311            result.unwrap_err().to_string().contains("does not exist"),
312            "expected 'does not exist'"
313        );
314    }
315
316    #[test]
317    fn test_write_file_checks_permissions() {
318        let dir = tempfile::TempDir::new().unwrap();
319        let perms = vec![crate::vcs::Permission {
320            path_prefix: "/restricted".to_string(),
321            principal: "alice".to_string(),
322            access: AccessLevel::Write,
323        }];
324        let gate = PermissionGate::from_permissions(dir.path(), &perms, AccessLevel::None);
325        let backend = MockBackend::new();
326
327        let service = RepoService::with_gate(Box::new(backend), dir.path(), gate);
328
329        // Alice can write to restricted.
330        assert!(
331            service
332                .write_file("restricted/secret.txt", "data", "alice")
333                .is_ok()
334        );
335
336        // Bob cannot.
337        let result = service.write_file("restricted/secret.txt", "data", "bob");
338        assert!(result.is_err());
339        assert!(
340            result.unwrap_err().to_string().contains("denied"),
341            "expected permission denied"
342        );
343    }
344
345    #[test]
346    fn test_read_file_checks_permissions() {
347        let dir = tempfile::TempDir::new().unwrap();
348        let perms = vec![crate::vcs::Permission {
349            path_prefix: "/".to_string(),
350            principal: "*".to_string(),
351            access: AccessLevel::Read,
352        }];
353        let gate = PermissionGate::from_permissions(dir.path(), &perms, AccessLevel::None);
354        let backend = MockBackend::new();
355
356        let service = RepoService::with_gate(Box::new(backend), dir.path(), gate);
357
358        // Write a file (bypassing gate for setup).
359        std::fs::write(dir.path().join("readme.md"), "hello").unwrap();
360
361        // Anyone can read.
362        assert!(service.read_file("readme.md", "bob").is_ok());
363
364        // But write is denied (read-only default on "/").
365        let result = service.write_file("newfile.txt", "data", "bob");
366        assert!(result.is_err());
367    }
368
369    #[test]
370    fn test_context_docs_integration() {
371        let dir = tempfile::TempDir::new().unwrap();
372        let backend = MockBackend::new();
373        let service = RepoService::with_gate(
374            Box::new(backend),
375            dir.path(),
376            PermissionGate::permissive(dir.path()),
377        );
378
379        service
380            .register_context_doc("task.md", &[("status", "active")])
381            .unwrap();
382        service.add_context_dep("task.md", "spec.md").unwrap();
383
384        let docs = service.all_context_docs().unwrap();
385        // task.md should exist (spec.md was auto-vivified by context_docs)
386        let task_doc = docs.iter().find(|d| d.path == "task.md");
387        assert!(task_doc.is_some(), "task.md should be in context docs");
388        assert_eq!(task_doc.unwrap().metadata.get("status").unwrap(), "active");
389    }
390}