1use 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#[derive(Debug, Clone)]
47pub struct RepoOptions {
48 pub description: String,
50 pub public: bool,
52 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
66pub struct RepoService {
78 backend: Box<dyn VcsBackend>,
80 workspace_root: PathBuf,
82 pub permission_gate: PermissionGate,
84 pub context_docs: ContextDocsManager,
86}
87
88impl RepoService {
89 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 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 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 pub fn backend(&self) -> &dyn VcsBackend {
130 self.backend.as_ref()
131 }
132
133 pub fn workspace_root(&self) -> &Path {
135 &self.workspace_root
136 }
137
138 pub fn create_repository(
144 &self,
145 workspace_id: &str,
146 repo_id: &str,
147 _opts: RepoOptions,
148 ) -> Result<Repository, NapError> {
149 let remote_url = match self.backend.remote_url_base() {
154 Ok(base) => format!("{}/{}", base.trim_end_matches('/'), repo_id),
155 Err(_) => {
156 format!("lore://localhost:8700/{}", repo_id)
158 }
159 };
160
161 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 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 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 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 pub fn commit(&self, message: &str, author: &str) -> Result<String, NapError> {
230 self.backend.commit(&self.workspace_root, message, author)
231 }
232
233 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 pub fn create_branch(&self, name: &str) -> Result<(), NapError> {
240 self.backend.create_branch(&self.workspace_root, name)
241 }
242
243 pub fn switch_branch(&self, name: &str) -> Result<(), NapError> {
245 self.backend.switch_branch(&self.workspace_root, name)
246 }
247
248 pub fn current_branch(&self) -> Result<String, NapError> {
250 self.backend.current_branch(&self.workspace_root)
251 }
252
253 pub fn list_branches(&self) -> Result<Vec<String>, NapError> {
255 self.backend.list_branches(&self.workspace_root)
256 }
257
258 pub fn push(&self, remote: Option<&str>, branch: Option<&str>) -> Result<(), NapError> {
260 self.backend.push(&self.workspace_root, remote, branch)
261 }
262
263 pub fn pull(&self, remote: Option<&str>, branch: Option<&str>) -> Result<(), NapError> {
265 self.backend.pull(&self.workspace_root, remote, branch)
266 }
267
268 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 pub fn add_context_dep(&self, source: &str, target: &str) -> Result<(), NapError> {
281 self.context_docs.add_dependency(source, target)
282 }
283
284 pub fn all_context_docs(&self) -> Result<Vec<ContextDocument>, NapError> {
286 self.context_docs.all_documents()
287 }
288}
289
290#[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 assert!(
331 service
332 .write_file("restricted/secret.txt", "data", "alice")
333 .is_ok()
334 );
335
336 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 std::fs::write(dir.path().join("readme.md"), "hello").unwrap();
360
361 assert!(service.read_file("readme.md", "bob").is_ok());
363
364 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 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}