1use 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#[derive(Debug, Clone)]
51pub struct RepoOptions {
52 pub description: String,
54 pub public: bool,
56 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
70pub struct RepoService {
82 backend: Box<dyn VcsBackend>,
84 workspace_root: PathBuf,
86 pub permission_gate: PermissionGate,
88 pub context_docs: ContextDocsManager,
90}
91
92impl RepoService {
93 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 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 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 pub fn backend(&self) -> &dyn VcsBackend {
134 self.backend.as_ref()
135 }
136
137 pub fn workspace_root(&self) -> &Path {
139 &self.workspace_root
140 }
141
142 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 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 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 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 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 pub fn commit(&self, message: &str, author: &str) -> Result<String, NapError> {
229 self.backend.commit(&self.workspace_root, message, author)
230 }
231
232 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 pub fn create_branch(&self, name: &str) -> Result<(), NapError> {
239 self.backend.create_branch(&self.workspace_root, name)
240 }
241
242 pub fn switch_branch(&self, name: &str) -> Result<(), NapError> {
244 self.backend.switch_branch(&self.workspace_root, name)
245 }
246
247 pub fn current_branch(&self) -> Result<String, NapError> {
249 self.backend.current_branch(&self.workspace_root)
250 }
251
252 pub fn list_branches(&self) -> Result<Vec<String>, NapError> {
254 self.backend.list_branches(&self.workspace_root)
255 }
256
257 pub fn create_tag(&self, name: &str) -> Result<(), NapError> {
259 self.backend.create_tag(&self.workspace_root, name)
260 }
261
262 pub fn list_tags(&self) -> Result<Vec<String>, NapError> {
264 self.backend.list_tags(&self.workspace_root)
265 }
266
267 pub fn push(&self, remote: Option<&str>, branch: Option<&str>) -> Result<(), NapError> {
269 self.backend.push(&self.workspace_root, remote, branch)
270 }
271
272 pub fn pull(&self, remote: Option<&str>, branch: Option<&str>) -> Result<(), NapError> {
274 self.backend.pull(&self.workspace_root, remote, branch)
275 }
276
277 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 pub fn add_context_dep(&self, source: &str, target: &str) -> Result<(), NapError> {
290 self.context_docs.add_dependency(source, target)
291 }
292
293 pub fn all_context_docs(&self) -> Result<Vec<ContextDocument>, NapError> {
295 self.context_docs.all_documents()
296 }
297
298 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#[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 assert!(
356 service
357 .write_file("restricted/secret.txt", "data", "alice")
358 .is_ok()
359 );
360
361 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 std::fs::write(dir.path().join("readme.md"), "hello").unwrap();
385
386 assert!(service.read_file("readme.md", "bob").is_ok());
388
389 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 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}