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 {
215 let base = std::env::var("NAP_LORE_URL_BASE")
216 .unwrap_or_else(|_| "lore://localhost:41337".to_string());
217 let workspace_id =
218 std::env::var("NAP_WORKSPACE_ID").unwrap_or_else(|_| "default".to_string());
219 let grpc_client = LoreGrpcClient::builder_from_env().unwrap_or_else(|e| {
222 tracing::warn!("failed to initialise gRPC client from env: {e}");
224 None
225 });
226 Self {
227 remote_url: base,
228 workspace_id,
229 grpc_client,
230 }
231 }
232
233 pub fn from_provider(url_base: &str, workspace_id: &str) -> Self {
237 tracing::debug!(
238 url_base = %url_base,
239 workspace_id = %workspace_id,
240 "Creating LoreBackend from provider configuration"
241 );
242
243 let grpc_endpoint = url_base
245 .replace("lore://", "https://")
246 .replace("lores://", "https://");
247
248 let grpc_client = crate::grpc_client::Builder::default()
249 .endpoint(grpc_endpoint)
250 .insecure(true) .build()
252 .map_err(|e| {
253 tracing::warn!("failed to initialise gRPC client from provider URL: {e}");
254 e
255 })
256 .ok();
257
258 Self {
259 remote_url: url_base.to_string(),
260 workspace_id: workspace_id.to_string(),
261 grpc_client,
262 }
263 }
264
265 fn repo_url(&self, repo_id: &str) -> String {
267 format!("{}/{}", self.remote_url.trim_end_matches('/'), repo_id)
268 }
269}
270
271impl VcsBackend for LoreBackend {
272 fn remote_url_base(&self) -> Result<String, NapError> {
274 Ok(self.remote_url.clone())
275 }
276
277 fn init(&self, path: &Path) -> Result<(), NapError> {
279 let repo_id = path
288 .file_name()
289 .and_then(|n| n.to_str())
290 .unwrap_or("nap-repo");
291
292 let url = self.repo_url(repo_id);
293 let path_str = path.to_str().unwrap_or(".");
294
295 let server_path = path
297 .parent()
298 .unwrap_or(path)
299 .join(".lore-server")
300 .join(repo_id);
301
302 LoreProcessRunner::run(
304 [
305 "repository",
306 "create",
307 &url,
308 "--id",
309 &self.workspace_id,
310 "--repository",
311 server_path.to_str().unwrap_or("."),
312 "--non-interactive",
313 ],
314 None,
315 )
316 .map_err(|e| {
317 NapError::VcsError(format!("failed to create lore repository '{}': {}", url, e))
318 })?;
319
320 LoreProcessRunner::run(["clone", &url, path_str, "--non-interactive"], None).map_err(
322 |e| {
323 NapError::VcsError(format!(
324 "failed to clone lore repository to {:?}: {}",
325 path, e
326 ))
327 },
328 )?;
329
330 Ok(())
331 }
332
333 fn commit(&self, path: &Path, message: &str, author: &str) -> Result<String, NapError> {
335 LoreProcessRunner::run(["stage", "--scan", ".", "--non-interactive"], Some(path))?;
338
339 let stdout = LoreProcessRunner::run(
341 [
342 "revision",
343 "commit",
344 message,
345 "--identity",
346 author,
347 "--non-interactive",
348 ],
349 Some(path),
350 )?;
351
352 let signature = stdout
355 .lines()
356 .find_map(|line| {
357 line.strip_prefix("Signature :")
358 .or_else(|| line.strip_prefix("Signature:"))
359 })
360 .map(|s| s.trim().to_string())
361 .unwrap_or_else(|| {
362 stdout
364 .lines()
365 .next()
366 .unwrap_or(&stdout)
367 .trim()
368 .strip_prefix("Created revision ")
369 .and_then(|s| s.split_whitespace().next())
370 .map(|s| s.to_string())
371 .unwrap_or_else(|| stdout.trim().to_string())
372 });
373
374 Ok(signature)
375 }
376
377 fn read_file_at_ref(
379 &self,
380 repo_path: &Path,
381 file_path: &str,
382 _reference: Option<&str>,
383 ) -> Result<String, NapError> {
384 let full_path = repo_path.join(file_path);
387 std::fs::read_to_string(&full_path).map_err(|e| {
388 NapError::VcsError(format!("failed to read {}: {}", full_path.display(), e))
389 })
390 }
391
392 fn log(
394 &self,
395 path: &Path,
396 _file: Option<&str>,
397 limit: usize,
398 ) -> Result<Vec<CommitInfo>, NapError> {
399 let limit_str = limit.to_string();
400 let args = vec!["history", &limit_str, "--non-interactive"];
401
402 let stdout = LoreProcessRunner::run(&args, Some(path))?;
403
404 if stdout.trim().is_empty() {
405 return Ok(Vec::new());
406 }
407
408 let mut commits = Vec::new();
417 let mut current_signature = String::new();
418 let mut current_author = String::new();
419 let mut current_message = String::new();
420 let mut current_timestamp = String::new();
421 let mut current_parent: Option<String> = None;
422 let mut in_message = false;
423
424 for line in stdout.lines() {
425 let trimmed = line.trim();
426 if trimmed.starts_with("Signature :") || trimmed.starts_with("Signature:") {
427 if !current_signature.is_empty() {
429 commits.push(CommitInfo {
430 id: std::mem::take(&mut current_signature),
431 parent: current_parent.take(),
432 author: std::mem::take(&mut current_author),
433 message: std::mem::take(&mut current_message),
434 timestamp: std::mem::take(&mut current_timestamp),
435 });
436 }
437 current_signature = trimmed
438 .strip_prefix("Signature :")
439 .or_else(|| trimmed.strip_prefix("Signature:"))
440 .unwrap_or("")
441 .trim()
442 .to_string();
443 in_message = false;
444 } else if trimmed.starts_with("Date :") || trimmed.starts_with("Date:") {
445 current_timestamp = trimmed
446 .split_once(':')
447 .map(|(_, v)| v.trim().to_string())
448 .unwrap_or_default();
449 in_message = true;
450 } else if trimmed.starts_with("Creator :") || trimmed.starts_with("Creator:") {
451 current_author = trimmed
452 .split_once(':')
453 .map(|(_, v)| v.trim().to_string())
454 .unwrap_or_default();
455 in_message = false;
456 } else if trimmed.starts_with("Revision :")
457 || trimmed.starts_with("Revision:")
458 || trimmed.starts_with("Branch :")
459 || trimmed.starts_with("Branch:")
460 || trimmed.starts_with("Committer :")
461 || trimmed.starts_with("Committer:")
462 {
463 in_message = false;
464 } else if in_message {
465 if trimmed.is_empty() || trimmed == "Commit succeeded" {
466 in_message = false;
467 } else {
468 if !current_message.is_empty() {
469 current_message.push('\n');
470 }
471 current_message.push_str(trimmed);
472 }
473 }
474 }
475 if !current_signature.is_empty() {
477 commits.push(CommitInfo {
478 id: current_signature,
479 parent: current_parent,
480 author: current_author,
481 message: current_message,
482 timestamp: current_timestamp,
483 });
484 }
485
486 Ok(commits)
487 }
488
489 fn create_branch(&self, path: &Path, name: &str) -> Result<(), NapError> {
491 LoreProcessRunner::run(["branch", "create", name, "--non-interactive"], Some(path))?;
492 Ok(())
493 }
494
495 fn switch_branch(&self, path: &Path, name: &str) -> Result<(), NapError> {
496 LoreProcessRunner::run(["branch", "switch", name, "--non-interactive"], Some(path))?;
497 Ok(())
498 }
499
500 fn current_branch(&self, path: &Path) -> Result<String, NapError> {
501 let stdout = LoreProcessRunner::run(["branch", "show", "--non-interactive"], Some(path))?;
502 Ok(stdout.trim().to_string())
503 }
504
505 fn list_branches(&self, path: &Path) -> Result<Vec<String>, NapError> {
506 let stdout = LoreProcessRunner::run(["branch", "list", "--non-interactive"], Some(path))?;
507 if stdout.is_empty() {
508 return Ok(Vec::new());
509 }
510 let mut branches = Vec::new();
517 let mut in_local = false;
518 for line in stdout.lines() {
519 let trimmed = line.trim();
520 if trimmed.starts_with("Local branches") {
521 in_local = true;
522 continue;
523 }
524 if trimmed.starts_with("Remote branches") {
525 in_local = false;
526 continue;
527 }
528 if in_local && !trimmed.is_empty() {
529 let name = trimmed.strip_prefix("* ").unwrap_or(trimmed);
531 branches.push(name.to_string());
532 }
533 }
534 Ok(branches)
535 }
536
537 fn create_tag(&self, path: &Path, name: &str) -> Result<(), NapError> {
539 let current = LoreProcessRunner::run(
543 [
544 "file",
545 "metadata",
546 "get",
547 "--key",
548 "nap.labels",
549 "--format",
550 "json",
551 "--non-interactive",
552 ],
553 Some(path),
554 )
555 .unwrap_or_else(|_| "[]".to_string());
556
557 let mut labels: Vec<String> = serde_json::from_str(¤t).unwrap_or_default();
558 if !labels.contains(&name.to_string()) {
559 labels.push(name.to_string());
560 }
561
562 let labels_json = serde_json::to_string(&labels)
563 .map_err(|e| NapError::VcsError(format!("failed to serialise label list: {}", e)))?;
564
565 LoreProcessRunner::run(
566 [
567 "file",
568 "metadata",
569 "set",
570 "--key",
571 "nap.labels",
572 "--value",
573 &labels_json,
574 "--non-interactive",
575 ],
576 Some(path),
577 )?;
578
579 Ok(())
580 }
581
582 fn list_tags(&self, path: &Path) -> Result<Vec<String>, NapError> {
583 let stdout = LoreProcessRunner::run(
584 [
585 "file",
586 "metadata",
587 "get",
588 "--key",
589 "nap.labels",
590 "--format",
591 "json",
592 "--non-interactive",
593 ],
594 Some(path),
595 )?;
596
597 if stdout.is_empty() || stdout == "[]" || stdout == "null" {
598 return Ok(Vec::new());
599 }
600
601 let labels: Vec<String> = serde_json::from_str(&stdout).map_err(|e| {
602 NapError::VcsError(format!(
603 "failed to parse lore labels JSON: {}. Raw: {}",
604 e, stdout
605 ))
606 })?;
607 Ok(labels)
608 }
609
610 fn head_hash(&self, path: &Path) -> Result<String, NapError> {
612 let stdout = LoreProcessRunner::run(["history", "1", "--non-interactive"], Some(path))?;
613
614 if stdout.trim().is_empty() {
615 return Err(NapError::VcsError(
616 "no commits in lore workspace".to_string(),
617 ));
618 }
619
620 stdout
622 .lines()
623 .find_map(|line| {
624 line.trim()
625 .strip_prefix("Signature :")
626 .or_else(|| line.trim().strip_prefix("Signature:"))
627 })
628 .map(|s| s.trim().to_string())
629 .ok_or_else(|| {
630 NapError::VcsError(format!(
631 "failed to parse signature from lore history: {stdout}"
632 ))
633 })
634 }
635
636 fn revert(&self, path: &Path, commit_hash: &str) -> Result<String, NapError> {
637 let stdout = LoreProcessRunner::run(
638 ["revision", "revert", commit_hash, "--non-interactive"],
639 Some(path),
640 )?;
641 let signature = stdout
643 .trim()
644 .strip_prefix("Created revert revision ")
645 .unwrap_or(stdout.trim());
646 Ok(signature.to_string())
647 }
648
649 fn resolve_branch_head(&self, path: &Path, branch: &str) -> Result<String, NapError> {
650 let stdout = LoreProcessRunner::run(
651 ["history", "1", "--branch", branch, "--non-interactive"],
652 Some(path),
653 )?;
654
655 if stdout.trim().is_empty() {
656 return Err(NapError::VcsError(format!(
657 "no commits found on branch '{branch}'"
658 )));
659 }
660
661 stdout
663 .lines()
664 .find_map(|line| {
665 line.trim()
666 .strip_prefix("Signature :")
667 .or_else(|| line.trim().strip_prefix("Signature:"))
668 })
669 .map(|s| s.trim().to_string())
670 .ok_or_else(|| {
671 NapError::VcsError(format!(
672 "failed to parse signature from lore history on branch '{branch}': {stdout}"
673 ))
674 })
675 }
676
677 fn add_remote(&self, path: &Path, name: &str, url: &str) -> Result<(), NapError> {
679 LoreProcessRunner::run(
680 [
681 "repository",
682 "add",
683 url,
684 "--alias",
685 name,
686 "--non-interactive",
687 ],
688 Some(path),
689 )?;
690 Ok(())
691 }
692
693 fn remove_remote(&self, path: &Path, name: &str) -> Result<(), NapError> {
694 LoreProcessRunner::run(
695 ["repository", "remove", "--alias", name, "--non-interactive"],
696 Some(path),
697 )?;
698 Ok(())
699 }
700
701 fn list_remotes(&self, path: &Path) -> Result<Vec<(String, String)>, NapError> {
702 let stdout = LoreProcessRunner::run(
703 [
704 "repository",
705 "list",
706 "--format",
707 "json",
708 "--non-interactive",
709 ],
710 Some(path),
711 )?;
712
713 if stdout.is_empty() || stdout == "[]" || stdout == "null" {
714 return Ok(Vec::new());
715 }
716
717 #[derive(serde::Deserialize)]
719 struct RemoteEntry {
720 #[allow(dead_code)]
721 name: String,
722 #[allow(dead_code)]
723 url: String,
724 }
725 let entries: Vec<RemoteEntry> = serde_json::from_str(&stdout).map_err(|e| {
726 NapError::VcsError(format!(
727 "failed to parse lore repository list JSON: {}. Raw: {}",
728 e, stdout
729 ))
730 })?;
731
732 let pairs: Vec<(String, String)> = entries.into_iter().map(|e| (e.name, e.url)).collect();
733 Ok(pairs)
734 }
735
736 fn push(
738 &self,
739 path: &Path,
740 remote: Option<&str>,
741 branch: Option<&str>,
742 ) -> Result<(), NapError> {
743 let mut args = vec!["revision", "publish", "--non-interactive"];
745 if let Some(r) = remote {
746 args.push("--remote");
747 args.push(r);
748 }
749 LoreProcessRunner::run(&args, Some(path))?;
750
751 if let Some(grpc) = self.grpc_client.clone() {
753 let branch_name = match branch {
756 Some(b) => b.to_string(),
757 None => self
758 .current_branch(path)
759 .unwrap_or_else(|_| "main".to_string()),
760 };
761
762 let local_head = self.head_hash(path)?;
765 let sig_raw = hex::decode(&local_head).map_err(|e| {
766 NapError::VcsError(format!("cannot decode head hash '{local_head}': {e}"))
767 })?;
768 let sig_bytes = bytes::Bytes::from(sig_raw);
769
770 block_on_grpc(async move {
771 let branch_record = grpc.get_branch_by_name(&branch_name).await?;
773 grpc.push_branch(branch_record.id, sig_bytes, false).await?;
775 tracing::debug!("gRPC ref sync: pushed {local_head} to branch {branch_name}");
776 Ok(())
777 })?;
778 }
779
780 Ok(())
781 }
782
783 fn pull(
784 &self,
785 path: &Path,
786 remote: Option<&str>,
787 branch: Option<&str>,
788 ) -> Result<(), NapError> {
789 if let Some(grpc) = self.grpc_client.clone() {
792 let branch_name = match branch {
793 Some(b) => b.to_string(),
794 None => self
795 .current_branch(path)
796 .unwrap_or_else(|_| "main".to_string()),
797 };
798
799 let branch_for_grpc = branch_name.clone();
800 let remote_tip = block_on_grpc(async move {
801 let branch_record = grpc.get_branch_by_name(&branch_for_grpc).await?;
802 Ok::<String, NapError>(hex::encode(&branch_record.latest))
803 })?;
804
805 tracing::info!("remote branch '{branch_name}' tip: {remote_tip}");
806 }
807
808 let mut args = vec!["update", "--non-interactive"];
810 if let Some(r) = remote {
811 args.push("--remote");
812 args.push(r);
813 }
814 LoreProcessRunner::run(&args, Some(path))?;
815
816 Ok(())
817 }
818}
819
820#[cfg(all(test, feature = "lore-integration"))]
825mod tests {
826 use super::*;
827
828 #[test]
831 fn test_binary_default() {
832 assert_eq!(LoreProcessRunner::binary(), "lore");
833 }
834
835 #[test]
836 fn test_binary_from_env() {
837 temp_env::with_var("NAPLORE_CLI", Some("/custom/lore"), || {
838 assert_eq!(LoreProcessRunner::binary(), "/custom/lore");
839 });
840 }
841
842 #[test]
843 fn test_run_captures_stdout() {
844 temp_env::with_var("NAPLORE_CLI", Some("lore-nonexistent-binary-12345"), || {
848 let result = LoreProcessRunner::run(["--version"], None);
849 assert!(result.is_err());
850 let err = result.unwrap_err().to_string();
851 assert!(
852 err.contains("lore-nonexistent-binary-12345"),
853 "error: {}",
854 err
855 );
856 });
857 }
858
859 #[test]
862 fn test_new_and_from_env() {
863 let backend = LoreBackend::new("lore://myhost:8700", "test-workspace");
864 assert_eq!(backend.remote_url, "lore://myhost:8700");
865 assert_eq!(backend.workspace_id, "test-workspace");
866
867 temp_env::with_vars(
868 vec![
869 ("NAP_LORE_URL_BASE", Some("lore://custom:9999")),
870 ("NAP_WORKSPACE_ID", Some("custom-ws")),
871 ],
872 || {
873 let from_env = LoreBackend::from_env();
874 assert_eq!(from_env.remote_url, "lore://custom:9999");
875 assert_eq!(from_env.workspace_id, "custom-ws");
876 },
877 );
878 }
879
880 #[test]
881 fn test_repo_url_joining() {
882 let backend = LoreBackend::new("lore://localhost:8700", "ws");
883 assert_eq!(backend.repo_url("my-repo"), "lore://localhost:8700/my-repo");
884
885 let backend2 = LoreBackend::new("lore://host:8700/", "ws");
887 assert_eq!(backend2.repo_url("foo"), "lore://host:8700/foo");
888 }
889
890 #[test]
891 fn test_list_branches_empty_json() {
892 }
897
898 #[test]
899 fn test_commit_parses_signature_from_stdout() {
900 let sample = "Created revision a1b2c3d4 (#42)";
904 let signature = sample
905 .strip_prefix("Created revision ")
906 .and_then(|s| s.split_whitespace().next())
907 .unwrap_or(sample);
908 assert_eq!(signature, "a1b2c3d4");
909 }
910
911 #[test]
914 fn test_commit_info_from_lore_revision() {
915 let info = CommitInfo::from_lore_revision(
916 "sig123",
917 Some("sig122"),
918 "alice",
919 "feat: add manifest",
920 "2026-06-30T12:00:00Z",
921 );
922 assert_eq!(info.id, "sig123");
923 assert_eq!(info.parent.as_deref(), Some("sig122"));
924 assert_eq!(info.author, "alice");
925 assert_eq!(info.message, "feat: add manifest");
926 assert_eq!(info.timestamp, "2026-06-30T12:00:00Z");
927 }
928
929 #[test]
930 fn test_commit_info_default_timestamp() {
931 let info = CommitInfo::from_lore_revision("sig", None, "bob", "msg", "");
933 assert!(
934 info.timestamp.contains('T') || info.timestamp.contains('Z'),
935 "expected RFC 3339 timestamp, got: {}",
936 info.timestamp
937 );
938 }
939}