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