1use std::path::Path;
36use std::process::Command;
37
38use crate::error::NapError;
39use crate::grpc_client::{LoreGrpcClient, block_on_grpc};
40use crate::vcs::{CommitInfo, VcsBackend};
41
42struct LoreProcessRunner;
59
60impl LoreProcessRunner {
61 fn binary() -> String {
64 std::env::var("NAPLORE_CLI").unwrap_or_else(|_| "lore".to_string())
65 }
66
67 fn run<I, S>(args: I, cwd: Option<&Path>) -> Result<String, NapError>
71 where
72 I: IntoIterator<Item = S>,
73 S: AsRef<std::ffi::OsStr>,
74 {
75 let bin = Self::binary();
76 let mut cmd = Command::new(&bin);
77 cmd.args(args);
78
79 if let Some(dir) = cwd {
80 cmd.current_dir(dir);
81 }
82
83 let output = cmd.output().map_err(|e| {
85 NapError::VcsError(format!(
86 "failed to execute `{}`: {}. Is `{}` installed and on $PATH?",
87 bin, e, bin
88 ))
89 })?;
90
91 if output.status.success() {
92 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
93 return Ok(stdout);
94 }
95
96 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
98 let exit_code = output.status.code().unwrap_or(-1);
99
100 let nap_err = match exit_code {
104 1 => {
105 if stderr.contains("not a lore workspace")
107 || stderr.contains("not an initialised lore workspace")
108 {
109 NapError::VcsError(format!(
110 "not a lore workspace at {:?}",
111 cwd.unwrap_or(Path::new("."))
112 ))
113 } else if stderr.contains("not found") {
114 NapError::VcsError(format!("path not found in lore workspace: {}", stderr))
115 } else {
116 NapError::VcsError(format!(
117 "lore CLI exited with code {}: {}",
118 exit_code, stderr
119 ))
120 }
121 }
122 64..=126 => {
123 NapError::VcsError(format!(
125 "lore CLI configuration error ({}): {}",
126 exit_code, stderr
127 ))
128 }
129 _ => NapError::VcsError(format!(
130 "lore CLI exited with code {}: {}",
131 exit_code, stderr
132 )),
133 };
134
135 Err(nap_err)
136 }
137}
138
139#[derive(Debug, Clone)]
152pub struct LoreBackend {
153 remote_url: String,
155 workspace_id: String,
157 grpc_client: Option<LoreGrpcClient>,
160}
161
162impl LoreBackend {
163 pub fn new(remote_url: &str, workspace_id: &str) -> Self {
168 Self {
169 remote_url: remote_url.to_string(),
170 workspace_id: workspace_id.to_string(),
171 grpc_client: None,
172 }
173 }
174
175 pub fn with_grpc(mut self, client: LoreGrpcClient) -> Self {
182 self.grpc_client = Some(client);
183 self
184 }
185
186 pub fn clone_repo(url: &str, dest: &Path) -> Result<(), NapError> {
192 LoreProcessRunner::run(
193 [
194 "clone",
195 url,
196 dest.to_str().unwrap_or("."),
197 "--non-interactive",
198 ],
199 None,
200 )?;
201 Ok(())
202 }
203
204 pub fn from_env() -> Self {
212 let base = std::env::var("NAP_LORE_URL_BASE")
213 .unwrap_or_else(|_| "lore://localhost:8700".to_string());
214 let workspace_id =
215 std::env::var("NAP_WORKSPACE_ID").unwrap_or_else(|_| "default".to_string());
216 let grpc_client = LoreGrpcClient::builder_from_env().unwrap_or_else(|e| {
219 tracing::warn!("failed to initialise gRPC client from env: {e}");
221 None
222 });
223 Self {
224 remote_url: base,
225 workspace_id,
226 grpc_client,
227 }
228 }
229
230 fn repo_url(&self, repo_id: &str) -> String {
232 format!("{}/{}", self.remote_url.trim_end_matches('/'), repo_id)
233 }
234}
235
236impl VcsBackend for LoreBackend {
237 fn init(&self, path: &Path) -> Result<(), NapError> {
239 let repo_id = path
247 .file_name()
248 .and_then(|n| n.to_str())
249 .unwrap_or("nap-repo");
250
251 let url = self.repo_url(repo_id);
252
253 LoreProcessRunner::run(
255 [
256 "repository",
257 "create",
258 &url,
259 "--id",
260 &self.workspace_id,
261 "--non-interactive",
262 ],
263 None,
264 )
265 .map_err(|e| {
266 NapError::VcsError(format!("failed to create lore repository '{}': {}", url, e))
267 })?;
268
269 LoreProcessRunner::run(
271 [
272 "clone",
273 &url,
274 path.to_str().unwrap_or("."),
275 "--non-interactive",
276 ],
277 None,
278 )
279 .map_err(|e| {
280 NapError::VcsError(format!(
281 "failed to clone lore repository to {:?}: {}",
282 path, e
283 ))
284 })?;
285
286 Ok(())
287 }
288
289 fn commit(&self, path: &Path, message: &str, author: &str) -> Result<String, NapError> {
291 LoreProcessRunner::run(["stage", "--scan", "--non-interactive"], Some(path))?;
294
295 let stdout = LoreProcessRunner::run(
297 [
298 "revision",
299 "commit",
300 "--message",
301 message,
302 "--identity",
303 author,
304 "--non-interactive",
305 ],
306 Some(path),
307 )?;
308
309 let signature = stdout
313 .lines()
314 .next()
315 .unwrap_or(&stdout)
316 .trim()
317 .strip_prefix("Created revision ")
318 .and_then(|s| s.split_whitespace().next())
319 .map(|s| s.to_string())
320 .unwrap_or_else(|| stdout.trim().to_string());
321
322 Ok(signature)
323 }
324
325 fn read_file_at_ref(
327 &self,
328 repo_path: &Path,
329 file_path: &str,
330 reference: Option<&str>,
331 ) -> Result<String, NapError> {
332 let mut args = vec!["file", "cat", file_path, "--non-interactive"];
333 if let Some(ref_str) = reference {
334 args.push("--revision");
335 args.push(ref_str);
336 }
337 LoreProcessRunner::run(&args, Some(repo_path))
338 }
339
340 fn log(
342 &self,
343 path: &Path,
344 file: Option<&str>,
345 limit: usize,
346 ) -> Result<Vec<CommitInfo>, NapError> {
347 let limit_str = limit.to_string();
348 let mut args = vec![
349 "log",
350 "--limit",
351 &limit_str,
352 "--format",
353 "json",
354 "--non-interactive",
355 ];
356 if let Some(f) = file {
357 args.push("--path");
358 args.push(f);
359 }
360
361 let stdout = LoreProcessRunner::run(&args, Some(path))?;
362
363 if stdout.is_empty() || stdout == "[]" || stdout == "null" {
364 return Ok(Vec::new());
365 }
366
367 #[derive(serde::Deserialize)]
372 struct LoreRevision {
373 signature: String,
374 #[allow(dead_code)]
375 number: u64,
376 message: String,
377 author: String,
378 timestamp: Option<String>,
379 parent_signature: Option<String>,
380 }
381
382 let revs: Vec<LoreRevision> = serde_json::from_str(&stdout).map_err(|e| {
383 NapError::VcsError(format!(
384 "failed to parse lore log output as JSON: {}. Raw output: {}",
385 e, stdout
386 ))
387 })?;
388
389 Ok(revs
390 .into_iter()
391 .map(|r| {
392 CommitInfo::from_lore_revision(
393 &r.signature,
394 r.parent_signature.as_deref(),
395 &r.author,
396 &r.message,
397 r.timestamp.as_deref().unwrap_or(""),
398 )
399 })
400 .collect())
401 }
402
403 fn create_branch(&self, path: &Path, name: &str) -> Result<(), NapError> {
405 LoreProcessRunner::run(["branch", "create", name, "--non-interactive"], Some(path))?;
406 Ok(())
407 }
408
409 fn switch_branch(&self, path: &Path, name: &str) -> Result<(), NapError> {
410 LoreProcessRunner::run(["branch", "switch", name, "--non-interactive"], Some(path))?;
411 Ok(())
412 }
413
414 fn current_branch(&self, path: &Path) -> Result<String, NapError> {
415 let stdout = LoreProcessRunner::run(["branch", "show", "--non-interactive"], Some(path))?;
416 Ok(stdout.trim().to_string())
417 }
418
419 fn list_branches(&self, path: &Path) -> Result<Vec<String>, NapError> {
420 let stdout = LoreProcessRunner::run(
421 ["branch", "list", "--format", "json", "--non-interactive"],
422 Some(path),
423 )?;
424 if stdout.is_empty() || stdout == "[]" || stdout == "null" {
425 return Ok(Vec::new());
426 }
427 let branches: Vec<String> = serde_json::from_str(&stdout).map_err(|e| {
429 NapError::VcsError(format!(
430 "failed to parse lore branch list JSON: {}. Raw: {}",
431 e, stdout
432 ))
433 })?;
434 Ok(branches)
435 }
436
437 fn create_tag(&self, path: &Path, name: &str) -> Result<(), NapError> {
439 let current = LoreProcessRunner::run(
443 [
444 "file",
445 "metadata",
446 "get",
447 "--key",
448 "nap.labels",
449 "--format",
450 "json",
451 "--non-interactive",
452 ],
453 Some(path),
454 )
455 .unwrap_or_else(|_| "[]".to_string());
456
457 let mut labels: Vec<String> = serde_json::from_str(¤t).unwrap_or_default();
458 if !labels.contains(&name.to_string()) {
459 labels.push(name.to_string());
460 }
461
462 let labels_json = serde_json::to_string(&labels)
463 .map_err(|e| NapError::VcsError(format!("failed to serialise label list: {}", e)))?;
464
465 LoreProcessRunner::run(
466 [
467 "file",
468 "metadata",
469 "set",
470 "--key",
471 "nap.labels",
472 "--value",
473 &labels_json,
474 "--non-interactive",
475 ],
476 Some(path),
477 )?;
478
479 Ok(())
480 }
481
482 fn list_tags(&self, path: &Path) -> Result<Vec<String>, NapError> {
483 let stdout = LoreProcessRunner::run(
484 [
485 "file",
486 "metadata",
487 "get",
488 "--key",
489 "nap.labels",
490 "--format",
491 "json",
492 "--non-interactive",
493 ],
494 Some(path),
495 )?;
496
497 if stdout.is_empty() || stdout == "[]" || stdout == "null" {
498 return Ok(Vec::new());
499 }
500
501 let labels: Vec<String> = serde_json::from_str(&stdout).map_err(|e| {
502 NapError::VcsError(format!(
503 "failed to parse lore labels JSON: {}. Raw: {}",
504 e, stdout
505 ))
506 })?;
507 Ok(labels)
508 }
509
510 fn head_hash(&self, path: &Path) -> Result<String, NapError> {
512 let stdout = LoreProcessRunner::run(
513 [
514 "log",
515 "--limit",
516 "1",
517 "--format",
518 "json",
519 "--non-interactive",
520 ],
521 Some(path),
522 )?;
523
524 if stdout.is_empty() || stdout == "[]" || stdout == "null" {
525 return Err(NapError::VcsError(
526 "no commits in lore workspace".to_string(),
527 ));
528 }
529
530 #[derive(serde::Deserialize)]
531 struct HeadRev {
532 signature: String,
533 }
534 let revs: Vec<HeadRev> = serde_json::from_str(&stdout).map_err(|e| {
535 NapError::VcsError(format!(
536 "failed to parse lore log JSON for head_hash: {}. Raw: {}",
537 e, stdout
538 ))
539 })?;
540 revs.into_iter()
541 .next()
542 .map(|r| r.signature)
543 .ok_or_else(|| NapError::VcsError("empty revision list".to_string()))
544 }
545
546 fn revert(&self, path: &Path, commit_hash: &str) -> Result<String, NapError> {
547 let stdout = LoreProcessRunner::run(
548 ["revision", "revert", commit_hash, "--non-interactive"],
549 Some(path),
550 )?;
551 let signature = stdout
553 .trim()
554 .strip_prefix("Created revert revision ")
555 .unwrap_or(stdout.trim());
556 Ok(signature.to_string())
557 }
558
559 fn resolve_branch_head(&self, path: &Path, branch: &str) -> Result<String, NapError> {
560 let stdout = LoreProcessRunner::run(
561 [
562 "log",
563 "--branch",
564 branch,
565 "--limit",
566 "1",
567 "--format",
568 "json",
569 "--non-interactive",
570 ],
571 Some(path),
572 )?;
573
574 if stdout.is_empty() || stdout == "[]" || stdout == "null" {
575 return Err(NapError::VcsError(format!(
576 "no commits found on branch '{branch}'"
577 )));
578 }
579
580 #[derive(serde::Deserialize)]
581 struct BranchRev {
582 signature: String,
583 }
584 let revs: Vec<BranchRev> = serde_json::from_str(&stdout).map_err(|e| {
585 NapError::VcsError(format!(
586 "failed to parse lore log JSON for resolve_branch_head: {e}. Raw: {stdout}"
587 ))
588 })?;
589
590 revs.into_iter()
591 .next()
592 .map(|r| r.signature)
593 .ok_or_else(|| NapError::VcsError(format!("empty revision list for branch '{branch}'")))
594 }
595
596 fn add_remote(&self, path: &Path, name: &str, url: &str) -> Result<(), NapError> {
598 LoreProcessRunner::run(
599 [
600 "repository",
601 "add",
602 url,
603 "--alias",
604 name,
605 "--non-interactive",
606 ],
607 Some(path),
608 )?;
609 Ok(())
610 }
611
612 fn remove_remote(&self, path: &Path, name: &str) -> Result<(), NapError> {
613 LoreProcessRunner::run(
614 ["repository", "remove", "--alias", name, "--non-interactive"],
615 Some(path),
616 )?;
617 Ok(())
618 }
619
620 fn list_remotes(&self, path: &Path) -> Result<Vec<(String, String)>, NapError> {
621 let stdout = LoreProcessRunner::run(
622 [
623 "repository",
624 "list",
625 "--format",
626 "json",
627 "--non-interactive",
628 ],
629 Some(path),
630 )?;
631
632 if stdout.is_empty() || stdout == "[]" || stdout == "null" {
633 return Ok(Vec::new());
634 }
635
636 #[derive(serde::Deserialize)]
638 struct RemoteEntry {
639 #[allow(dead_code)]
640 name: String,
641 #[allow(dead_code)]
642 url: String,
643 }
644 let entries: Vec<RemoteEntry> = serde_json::from_str(&stdout).map_err(|e| {
645 NapError::VcsError(format!(
646 "failed to parse lore repository list JSON: {}. Raw: {}",
647 e, stdout
648 ))
649 })?;
650
651 let pairs: Vec<(String, String)> = entries.into_iter().map(|e| (e.name, e.url)).collect();
652 Ok(pairs)
653 }
654
655 fn push(
657 &self,
658 path: &Path,
659 remote: Option<&str>,
660 branch: Option<&str>,
661 ) -> Result<(), NapError> {
662 let mut args = vec!["revision", "publish", "--non-interactive"];
664 if let Some(r) = remote {
665 args.push("--remote");
666 args.push(r);
667 }
668 LoreProcessRunner::run(&args, Some(path))?;
669
670 if let Some(grpc) = self.grpc_client.clone() {
672 let branch_name = match branch {
675 Some(b) => b.to_string(),
676 None => self
677 .current_branch(path)
678 .unwrap_or_else(|_| "main".to_string()),
679 };
680
681 let local_head = self.head_hash(path)?;
684 let sig_raw = hex::decode(&local_head).map_err(|e| {
685 NapError::VcsError(format!("cannot decode head hash '{local_head}': {e}"))
686 })?;
687 let sig_bytes = bytes::Bytes::from(sig_raw);
688
689 block_on_grpc(async move {
690 let branch_record = grpc.get_branch_by_name(&branch_name).await?;
692 grpc.push_branch(branch_record.id, sig_bytes, false).await?;
694 tracing::debug!("gRPC ref sync: pushed {local_head} to branch {branch_name}");
695 Ok(())
696 })?;
697 }
698
699 Ok(())
700 }
701
702 fn pull(
703 &self,
704 path: &Path,
705 remote: Option<&str>,
706 branch: Option<&str>,
707 ) -> Result<(), NapError> {
708 if let Some(grpc) = self.grpc_client.clone() {
711 let branch_name = match branch {
712 Some(b) => b.to_string(),
713 None => self
714 .current_branch(path)
715 .unwrap_or_else(|_| "main".to_string()),
716 };
717
718 let branch_for_grpc = branch_name.clone();
719 let remote_tip = block_on_grpc(async move {
720 let branch_record = grpc.get_branch_by_name(&branch_for_grpc).await?;
721 Ok::<String, NapError>(hex::encode(&branch_record.latest))
722 })?;
723
724 tracing::info!("remote branch '{branch_name}' tip: {remote_tip}");
725 }
726
727 let mut args = vec!["update", "--non-interactive"];
729 if let Some(r) = remote {
730 args.push("--remote");
731 args.push(r);
732 }
733 LoreProcessRunner::run(&args, Some(path))?;
734
735 Ok(())
736 }
737}
738
739#[cfg(test)]
744mod tests {
745 use super::*;
746
747 #[test]
750 fn test_binary_default() {
751 assert_eq!(LoreProcessRunner::binary(), "lore");
752 }
753
754 #[test]
755 fn test_binary_from_env() {
756 temp_env::with_var("NAPLORE_CLI", Some("/custom/lore"), || {
757 assert_eq!(LoreProcessRunner::binary(), "/custom/lore");
758 });
759 }
760
761 #[test]
762 fn test_run_captures_stdout() {
763 temp_env::with_var("NAPLORE_CLI", Some("lore-nonexistent-binary-12345"), || {
767 let result = LoreProcessRunner::run(["--version"], None);
768 assert!(result.is_err());
769 let err = result.unwrap_err().to_string();
770 assert!(
771 err.contains("lore-nonexistent-binary-12345"),
772 "error: {}",
773 err
774 );
775 });
776 }
777
778 #[test]
781 fn test_new_and_from_env() {
782 let backend = LoreBackend::new("lore://myhost:8700", "test-workspace");
783 assert_eq!(backend.remote_url, "lore://myhost:8700");
784 assert_eq!(backend.workspace_id, "test-workspace");
785
786 temp_env::with_vars(
787 vec![
788 ("NAP_LORE_URL_BASE", Some("lore://custom:9999")),
789 ("NAP_WORKSPACE_ID", Some("custom-ws")),
790 ],
791 || {
792 let from_env = LoreBackend::from_env();
793 assert_eq!(from_env.remote_url, "lore://custom:9999");
794 assert_eq!(from_env.workspace_id, "custom-ws");
795 },
796 );
797 }
798
799 #[test]
800 fn test_repo_url_joining() {
801 let backend = LoreBackend::new("lore://localhost:8700", "ws");
802 assert_eq!(backend.repo_url("my-repo"), "lore://localhost:8700/my-repo");
803
804 let backend2 = LoreBackend::new("lore://host:8700/", "ws");
806 assert_eq!(backend2.repo_url("foo"), "lore://host:8700/foo");
807 }
808
809 #[test]
810 fn test_list_branches_empty_json() {
811 }
816
817 #[test]
818 fn test_commit_parses_signature_from_stdout() {
819 let sample = "Created revision a1b2c3d4 (#42)";
823 let signature = sample
824 .strip_prefix("Created revision ")
825 .and_then(|s| s.split_whitespace().next())
826 .unwrap_or(sample);
827 assert_eq!(signature, "a1b2c3d4");
828 }
829
830 #[test]
833 fn test_commit_info_from_lore_revision() {
834 let info = CommitInfo::from_lore_revision(
835 "sig123",
836 Some("sig122"),
837 "alice",
838 "feat: add manifest",
839 "2026-06-30T12:00:00Z",
840 );
841 assert_eq!(info.id, "sig123");
842 assert_eq!(info.parent.as_deref(), Some("sig122"));
843 assert_eq!(info.author, "alice");
844 assert_eq!(info.message, "feat: add manifest");
845 assert_eq!(info.timestamp, "2026-06-30T12:00:00Z");
846 }
847
848 #[test]
849 fn test_commit_info_default_timestamp() {
850 let info = CommitInfo::from_lore_revision("sig", None, "bob", "msg", "");
852 assert!(
853 info.timestamp.contains('T') || info.timestamp.contains('Z'),
854 "expected RFC 3339 timestamp, got: {}",
855 info.timestamp
856 );
857 }
858}