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 add_remote(&self, path: &Path, name: &str, url: &str) -> Result<(), NapError> {
561 LoreProcessRunner::run(
562 [
563 "repository",
564 "add",
565 url,
566 "--alias",
567 name,
568 "--non-interactive",
569 ],
570 Some(path),
571 )?;
572 Ok(())
573 }
574
575 fn remove_remote(&self, path: &Path, name: &str) -> Result<(), NapError> {
576 LoreProcessRunner::run(
577 ["repository", "remove", "--alias", name, "--non-interactive"],
578 Some(path),
579 )?;
580 Ok(())
581 }
582
583 fn list_remotes(&self, path: &Path) -> Result<Vec<(String, String)>, NapError> {
584 let stdout = LoreProcessRunner::run(
585 [
586 "repository",
587 "list",
588 "--format",
589 "json",
590 "--non-interactive",
591 ],
592 Some(path),
593 )?;
594
595 if stdout.is_empty() || stdout == "[]" || stdout == "null" {
596 return Ok(Vec::new());
597 }
598
599 #[derive(serde::Deserialize)]
601 struct RemoteEntry {
602 #[allow(dead_code)]
603 name: String,
604 #[allow(dead_code)]
605 url: String,
606 }
607 let entries: Vec<RemoteEntry> = serde_json::from_str(&stdout).map_err(|e| {
608 NapError::VcsError(format!(
609 "failed to parse lore repository list JSON: {}. Raw: {}",
610 e, stdout
611 ))
612 })?;
613
614 let pairs: Vec<(String, String)> = entries.into_iter().map(|e| (e.name, e.url)).collect();
615 Ok(pairs)
616 }
617
618 fn push(
620 &self,
621 path: &Path,
622 remote: Option<&str>,
623 branch: Option<&str>,
624 ) -> Result<(), NapError> {
625 let mut args = vec!["revision", "publish", "--non-interactive"];
627 if let Some(r) = remote {
628 args.push("--remote");
629 args.push(r);
630 }
631 LoreProcessRunner::run(&args, Some(path))?;
632
633 if let Some(grpc) = self.grpc_client.clone() {
635 let branch_name = match branch {
638 Some(b) => b.to_string(),
639 None => self
640 .current_branch(path)
641 .unwrap_or_else(|_| "main".to_string()),
642 };
643
644 let local_head = self.head_hash(path)?;
647 let sig_raw = hex::decode(&local_head).map_err(|e| {
648 NapError::VcsError(format!("cannot decode head hash '{local_head}': {e}"))
649 })?;
650 let sig_bytes = bytes::Bytes::from(sig_raw);
651
652 block_on_grpc(async move {
653 let branch_record = grpc.get_branch_by_name(&branch_name).await?;
655 grpc.push_branch(branch_record.id, sig_bytes, false).await?;
657 tracing::debug!("gRPC ref sync: pushed {local_head} to branch {branch_name}");
658 Ok(())
659 })?;
660 }
661
662 Ok(())
663 }
664
665 fn pull(
666 &self,
667 path: &Path,
668 remote: Option<&str>,
669 branch: Option<&str>,
670 ) -> Result<(), NapError> {
671 if let Some(grpc) = self.grpc_client.clone() {
674 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 branch_for_grpc = branch_name.clone();
682 let remote_tip = block_on_grpc(async move {
683 let branch_record = grpc.get_branch_by_name(&branch_for_grpc).await?;
684 Ok::<String, NapError>(hex::encode(&branch_record.latest))
685 })?;
686
687 tracing::info!("remote branch '{branch_name}' tip: {remote_tip}");
688 }
689
690 let mut args = vec!["update", "--non-interactive"];
692 if let Some(r) = remote {
693 args.push("--remote");
694 args.push(r);
695 }
696 LoreProcessRunner::run(&args, Some(path))?;
697
698 Ok(())
699 }
700}
701
702#[cfg(test)]
707mod tests {
708 use super::*;
709
710 #[test]
713 fn test_binary_default() {
714 assert_eq!(LoreProcessRunner::binary(), "lore");
715 }
716
717 #[test]
718 fn test_binary_from_env() {
719 temp_env::with_var("NAPLORE_CLI", Some("/custom/lore"), || {
720 assert_eq!(LoreProcessRunner::binary(), "/custom/lore");
721 });
722 }
723
724 #[test]
725 fn test_run_captures_stdout() {
726 temp_env::with_var("NAPLORE_CLI", Some("lore-nonexistent-binary-12345"), || {
730 let result = LoreProcessRunner::run(["--version"], None);
731 assert!(result.is_err());
732 let err = result.unwrap_err().to_string();
733 assert!(
734 err.contains("lore-nonexistent-binary-12345"),
735 "error: {}",
736 err
737 );
738 });
739 }
740
741 #[test]
744 fn test_new_and_from_env() {
745 let backend = LoreBackend::new("lore://myhost:8700", "test-workspace");
746 assert_eq!(backend.remote_url, "lore://myhost:8700");
747 assert_eq!(backend.workspace_id, "test-workspace");
748
749 temp_env::with_vars(
750 vec![
751 ("NAP_LORE_URL_BASE", Some("lore://custom:9999")),
752 ("NAP_WORKSPACE_ID", Some("custom-ws")),
753 ],
754 || {
755 let from_env = LoreBackend::from_env();
756 assert_eq!(from_env.remote_url, "lore://custom:9999");
757 assert_eq!(from_env.workspace_id, "custom-ws");
758 },
759 );
760 }
761
762 #[test]
763 fn test_repo_url_joining() {
764 let backend = LoreBackend::new("lore://localhost:8700", "ws");
765 assert_eq!(backend.repo_url("my-repo"), "lore://localhost:8700/my-repo");
766
767 let backend2 = LoreBackend::new("lore://host:8700/", "ws");
769 assert_eq!(backend2.repo_url("foo"), "lore://host:8700/foo");
770 }
771
772 #[test]
773 fn test_list_branches_empty_json() {
774 }
779
780 #[test]
781 fn test_commit_parses_signature_from_stdout() {
782 let sample = "Created revision a1b2c3d4 (#42)";
786 let signature = sample
787 .strip_prefix("Created revision ")
788 .and_then(|s| s.split_whitespace().next())
789 .unwrap_or(sample);
790 assert_eq!(signature, "a1b2c3d4");
791 }
792
793 #[test]
796 fn test_commit_info_from_lore_revision() {
797 let info = CommitInfo::from_lore_revision(
798 "sig123",
799 Some("sig122"),
800 "alice",
801 "feat: add manifest",
802 "2026-06-30T12:00:00Z",
803 );
804 assert_eq!(info.id, "sig123");
805 assert_eq!(info.parent.as_deref(), Some("sig122"));
806 assert_eq!(info.author, "alice");
807 assert_eq!(info.message, "feat: add manifest");
808 assert_eq!(info.timestamp, "2026-06-30T12:00:00Z");
809 }
810
811 #[test]
812 fn test_commit_info_default_timestamp() {
813 let info = CommitInfo::from_lore_revision("sig", None, "bob", "msg", "");
815 assert!(
816 info.timestamp.contains('T') || info.timestamp.contains('Z'),
817 "expected RFC 3339 timestamp, got: {}",
818 info.timestamp
819 );
820 }
821}