1use std::path::Path;
36use std::process::Command;
37
38use crate::error::NapError;
39use crate::vcs::{CommitInfo, VcsBackend};
40
41struct LoreProcessRunner;
58
59impl LoreProcessRunner {
60 fn binary() -> String {
63 std::env::var("NAPLORE_CLI").unwrap_or_else(|_| "lore".to_string())
64 }
65
66 fn run<I, S>(args: I, cwd: Option<&Path>) -> Result<String, NapError>
70 where
71 I: IntoIterator<Item = S>,
72 S: AsRef<std::ffi::OsStr>,
73 {
74 let bin = Self::binary();
75 let mut cmd = Command::new(&bin);
76 cmd.args(args);
77
78 if let Some(dir) = cwd {
79 cmd.current_dir(dir);
80 }
81
82 let output = cmd.output().map_err(|e| {
84 NapError::VcsError(format!(
85 "failed to execute `{}`: {}. Is `{}` installed and on $PATH?",
86 bin, e, bin
87 ))
88 })?;
89
90 if output.status.success() {
91 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
92 return Ok(stdout);
93 }
94
95 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
97 let exit_code = output.status.code().unwrap_or(-1);
98
99 let nap_err = match exit_code {
103 1 => {
104 if stderr.contains("not a lore workspace")
106 || stderr.contains("not an initialised lore workspace")
107 {
108 NapError::VcsError(format!(
109 "not a lore workspace at {:?}",
110 cwd.unwrap_or(Path::new("."))
111 ))
112 } else if stderr.contains("not found") {
113 NapError::VcsError(format!("path not found in lore workspace: {}", stderr))
114 } else {
115 NapError::VcsError(format!(
116 "lore CLI exited with code {}: {}",
117 exit_code, stderr
118 ))
119 }
120 }
121 64..=126 => {
122 NapError::VcsError(format!(
124 "lore CLI configuration error ({}): {}",
125 exit_code, stderr
126 ))
127 }
128 _ => NapError::VcsError(format!(
129 "lore CLI exited with code {}: {}",
130 exit_code, stderr
131 )),
132 };
133
134 Err(nap_err)
135 }
136}
137
138#[derive(Debug, Clone)]
151pub struct LoreBackend {
152 remote_url: String,
154 workspace_id: String,
156}
157
158impl LoreBackend {
159 pub fn new(remote_url: &str, workspace_id: &str) -> Self {
164 Self {
165 remote_url: remote_url.to_string(),
166 workspace_id: workspace_id.to_string(),
167 }
168 }
169
170 pub fn clone_repo(url: &str, dest: &Path) -> Result<(), NapError> {
176 LoreProcessRunner::run(
177 [
178 "clone",
179 url,
180 dest.to_str().unwrap_or("."),
181 "--non-interactive",
182 ],
183 None,
184 )?;
185 Ok(())
186 }
187
188 pub fn from_env() -> Self {
196 let base = std::env::var("NAP_LORE_URL_BASE")
197 .unwrap_or_else(|_| "lore://localhost:8700".to_string());
198 let workspace_id =
199 std::env::var("NAP_WORKSPACE_ID").unwrap_or_else(|_| "default".to_string());
200 Self::new(&base, &workspace_id)
202 }
203
204 fn repo_url(&self, repo_id: &str) -> String {
206 format!("{}/{}", self.remote_url.trim_end_matches('/'), repo_id)
207 }
208}
209
210impl VcsBackend for LoreBackend {
211 fn init(&self, path: &Path) -> Result<(), NapError> {
213 let repo_id = path
221 .file_name()
222 .and_then(|n| n.to_str())
223 .unwrap_or("nap-repo");
224
225 let url = self.repo_url(repo_id);
226
227 LoreProcessRunner::run(
229 [
230 "repository",
231 "create",
232 &url,
233 "--id",
234 &self.workspace_id,
235 "--non-interactive",
236 ],
237 None,
238 )
239 .map_err(|e| {
240 NapError::VcsError(format!("failed to create lore repository '{}': {}", url, e))
241 })?;
242
243 LoreProcessRunner::run(
245 [
246 "clone",
247 &url,
248 path.to_str().unwrap_or("."),
249 "--non-interactive",
250 ],
251 None,
252 )
253 .map_err(|e| {
254 NapError::VcsError(format!(
255 "failed to clone lore repository to {:?}: {}",
256 path, e
257 ))
258 })?;
259
260 Ok(())
261 }
262
263 fn commit(&self, path: &Path, message: &str, author: &str) -> Result<String, NapError> {
265 LoreProcessRunner::run(["stage", "--scan", "--non-interactive"], Some(path))?;
268
269 let stdout = LoreProcessRunner::run(
271 [
272 "revision",
273 "commit",
274 "--message",
275 message,
276 "--identity",
277 author,
278 "--non-interactive",
279 ],
280 Some(path),
281 )?;
282
283 let signature = stdout
287 .lines()
288 .next()
289 .unwrap_or(&stdout)
290 .trim()
291 .strip_prefix("Created revision ")
292 .and_then(|s| s.split_whitespace().next())
293 .map(|s| s.to_string())
294 .unwrap_or_else(|| stdout.trim().to_string());
295
296 Ok(signature)
297 }
298
299 fn read_file_at_ref(
301 &self,
302 repo_path: &Path,
303 file_path: &str,
304 reference: Option<&str>,
305 ) -> Result<String, NapError> {
306 let mut args = vec!["file", "cat", file_path, "--non-interactive"];
307 if let Some(ref_str) = reference {
308 args.push("--revision");
309 args.push(ref_str);
310 }
311 LoreProcessRunner::run(&args, Some(repo_path))
312 }
313
314 fn log(
316 &self,
317 path: &Path,
318 file: Option<&str>,
319 limit: usize,
320 ) -> Result<Vec<CommitInfo>, NapError> {
321 let limit_str = limit.to_string();
322 let mut args = vec![
323 "log",
324 "--limit",
325 &limit_str,
326 "--format",
327 "json",
328 "--non-interactive",
329 ];
330 if let Some(f) = file {
331 args.push("--path");
332 args.push(f);
333 }
334
335 let stdout = LoreProcessRunner::run(&args, Some(path))?;
336
337 if stdout.is_empty() || stdout == "[]" || stdout == "null" {
338 return Ok(Vec::new());
339 }
340
341 #[derive(serde::Deserialize)]
346 struct LoreRevision {
347 signature: String,
348 #[allow(dead_code)]
349 number: u64,
350 message: String,
351 author: String,
352 timestamp: Option<String>,
353 parent_signature: Option<String>,
354 }
355
356 let revs: Vec<LoreRevision> = serde_json::from_str(&stdout).map_err(|e| {
357 NapError::VcsError(format!(
358 "failed to parse lore log output as JSON: {}. Raw output: {}",
359 e, stdout
360 ))
361 })?;
362
363 Ok(revs
364 .into_iter()
365 .map(|r| {
366 CommitInfo::from_lore_revision(
367 &r.signature,
368 r.parent_signature.as_deref(),
369 &r.author,
370 &r.message,
371 r.timestamp.as_deref().unwrap_or(""),
372 )
373 })
374 .collect())
375 }
376
377 fn create_branch(&self, path: &Path, name: &str) -> Result<(), NapError> {
379 LoreProcessRunner::run(["branch", "create", name, "--non-interactive"], Some(path))?;
380 Ok(())
381 }
382
383 fn switch_branch(&self, path: &Path, name: &str) -> Result<(), NapError> {
384 LoreProcessRunner::run(["branch", "switch", name, "--non-interactive"], Some(path))?;
385 Ok(())
386 }
387
388 fn current_branch(&self, path: &Path) -> Result<String, NapError> {
389 let stdout = LoreProcessRunner::run(["branch", "show", "--non-interactive"], Some(path))?;
390 Ok(stdout.trim().to_string())
391 }
392
393 fn list_branches(&self, path: &Path) -> Result<Vec<String>, NapError> {
394 let stdout = LoreProcessRunner::run(
395 ["branch", "list", "--format", "json", "--non-interactive"],
396 Some(path),
397 )?;
398 if stdout.is_empty() || stdout == "[]" || stdout == "null" {
399 return Ok(Vec::new());
400 }
401 let branches: Vec<String> = serde_json::from_str(&stdout).map_err(|e| {
403 NapError::VcsError(format!(
404 "failed to parse lore branch list JSON: {}. Raw: {}",
405 e, stdout
406 ))
407 })?;
408 Ok(branches)
409 }
410
411 fn create_tag(&self, path: &Path, name: &str) -> Result<(), NapError> {
413 let current = LoreProcessRunner::run(
417 [
418 "file",
419 "metadata",
420 "get",
421 "--key",
422 "nap.labels",
423 "--format",
424 "json",
425 "--non-interactive",
426 ],
427 Some(path),
428 )
429 .unwrap_or_else(|_| "[]".to_string());
430
431 let mut labels: Vec<String> = serde_json::from_str(¤t).unwrap_or_default();
432 if !labels.contains(&name.to_string()) {
433 labels.push(name.to_string());
434 }
435
436 let labels_json = serde_json::to_string(&labels)
437 .map_err(|e| NapError::VcsError(format!("failed to serialise label list: {}", e)))?;
438
439 LoreProcessRunner::run(
440 [
441 "file",
442 "metadata",
443 "set",
444 "--key",
445 "nap.labels",
446 "--value",
447 &labels_json,
448 "--non-interactive",
449 ],
450 Some(path),
451 )?;
452
453 Ok(())
454 }
455
456 fn list_tags(&self, path: &Path) -> Result<Vec<String>, NapError> {
457 let stdout = LoreProcessRunner::run(
458 [
459 "file",
460 "metadata",
461 "get",
462 "--key",
463 "nap.labels",
464 "--format",
465 "json",
466 "--non-interactive",
467 ],
468 Some(path),
469 )?;
470
471 if stdout.is_empty() || stdout == "[]" || stdout == "null" {
472 return Ok(Vec::new());
473 }
474
475 let labels: Vec<String> = serde_json::from_str(&stdout).map_err(|e| {
476 NapError::VcsError(format!(
477 "failed to parse lore labels JSON: {}. Raw: {}",
478 e, stdout
479 ))
480 })?;
481 Ok(labels)
482 }
483
484 fn head_hash(&self, path: &Path) -> Result<String, NapError> {
486 let stdout = LoreProcessRunner::run(
487 [
488 "log",
489 "--limit",
490 "1",
491 "--format",
492 "json",
493 "--non-interactive",
494 ],
495 Some(path),
496 )?;
497
498 if stdout.is_empty() || stdout == "[]" || stdout == "null" {
499 return Err(NapError::VcsError(
500 "no commits in lore workspace".to_string(),
501 ));
502 }
503
504 #[derive(serde::Deserialize)]
505 struct HeadRev {
506 signature: String,
507 }
508 let revs: Vec<HeadRev> = serde_json::from_str(&stdout).map_err(|e| {
509 NapError::VcsError(format!(
510 "failed to parse lore log JSON for head_hash: {}. Raw: {}",
511 e, stdout
512 ))
513 })?;
514 revs.into_iter()
515 .next()
516 .map(|r| r.signature)
517 .ok_or_else(|| NapError::VcsError("empty revision list".to_string()))
518 }
519
520 fn revert(&self, path: &Path, commit_hash: &str) -> Result<String, NapError> {
521 let stdout = LoreProcessRunner::run(
522 ["revision", "revert", commit_hash, "--non-interactive"],
523 Some(path),
524 )?;
525 let signature = stdout
527 .trim()
528 .strip_prefix("Created revert revision ")
529 .unwrap_or(stdout.trim());
530 Ok(signature.to_string())
531 }
532
533 fn add_remote(&self, path: &Path, name: &str, url: &str) -> Result<(), NapError> {
535 LoreProcessRunner::run(
536 [
537 "repository",
538 "add",
539 url,
540 "--alias",
541 name,
542 "--non-interactive",
543 ],
544 Some(path),
545 )?;
546 Ok(())
547 }
548
549 fn remove_remote(&self, path: &Path, name: &str) -> Result<(), NapError> {
550 LoreProcessRunner::run(
551 ["repository", "remove", "--alias", name, "--non-interactive"],
552 Some(path),
553 )?;
554 Ok(())
555 }
556
557 fn list_remotes(&self, path: &Path) -> Result<Vec<(String, String)>, NapError> {
558 let stdout = LoreProcessRunner::run(
559 [
560 "repository",
561 "list",
562 "--format",
563 "json",
564 "--non-interactive",
565 ],
566 Some(path),
567 )?;
568
569 if stdout.is_empty() || stdout == "[]" || stdout == "null" {
570 return Ok(Vec::new());
571 }
572
573 #[derive(serde::Deserialize)]
575 struct RemoteEntry {
576 #[allow(dead_code)]
577 name: String,
578 #[allow(dead_code)]
579 url: String,
580 }
581 let entries: Vec<RemoteEntry> = serde_json::from_str(&stdout).map_err(|e| {
582 NapError::VcsError(format!(
583 "failed to parse lore repository list JSON: {}. Raw: {}",
584 e, stdout
585 ))
586 })?;
587
588 let pairs: Vec<(String, String)> = entries.into_iter().map(|e| (e.name, e.url)).collect();
589 Ok(pairs)
590 }
591
592 fn push(
594 &self,
595 path: &Path,
596 remote: Option<&str>,
597 _branch: Option<&str>,
598 ) -> Result<(), NapError> {
599 let mut args = vec!["revision", "publish", "--non-interactive"];
600 if let Some(r) = remote {
601 args.push("--remote");
602 args.push(r);
603 }
604 LoreProcessRunner::run(&args, Some(path))?;
605 Ok(())
606 }
607
608 fn pull(
609 &self,
610 path: &Path,
611 remote: Option<&str>,
612 _branch: Option<&str>,
613 ) -> Result<(), NapError> {
614 let mut args = vec!["update", "--non-interactive"];
615 if let Some(r) = remote {
616 args.push("--remote");
617 args.push(r);
618 }
619 LoreProcessRunner::run(&args, Some(path))?;
620 Ok(())
621 }
622}
623
624#[cfg(test)]
629mod tests {
630 use super::*;
631
632 #[test]
635 fn test_binary_default() {
636 assert_eq!(LoreProcessRunner::binary(), "lore");
637 }
638
639 #[test]
640 fn test_binary_from_env() {
641 temp_env::with_var("NAPLORE_CLI", Some("/custom/lore"), || {
642 assert_eq!(LoreProcessRunner::binary(), "/custom/lore");
643 });
644 }
645
646 #[test]
647 fn test_run_captures_stdout() {
648 temp_env::with_var("NAPLORE_CLI", Some("lore-nonexistent-binary-12345"), || {
652 let result = LoreProcessRunner::run(["--version"], None);
653 assert!(result.is_err());
654 let err = result.unwrap_err().to_string();
655 assert!(
656 err.contains("lore-nonexistent-binary-12345"),
657 "error: {}",
658 err
659 );
660 });
661 }
662
663 #[test]
666 fn test_new_and_from_env() {
667 let backend = LoreBackend::new("lore://myhost:8700", "test-workspace");
668 assert_eq!(backend.remote_url, "lore://myhost:8700");
669 assert_eq!(backend.workspace_id, "test-workspace");
670
671 temp_env::with_vars(
672 vec![
673 ("NAP_LORE_URL_BASE", Some("lore://custom:9999")),
674 ("NAP_WORKSPACE_ID", Some("custom-ws")),
675 ],
676 || {
677 let from_env = LoreBackend::from_env();
678 assert_eq!(from_env.remote_url, "lore://custom:9999");
679 assert_eq!(from_env.workspace_id, "custom-ws");
680 },
681 );
682 }
683
684 #[test]
685 fn test_repo_url_joining() {
686 let backend = LoreBackend::new("lore://localhost:8700", "ws");
687 assert_eq!(backend.repo_url("my-repo"), "lore://localhost:8700/my-repo");
688
689 let backend2 = LoreBackend::new("lore://host:8700/", "ws");
691 assert_eq!(backend2.repo_url("foo"), "lore://host:8700/foo");
692 }
693
694 #[test]
695 fn test_list_branches_empty_json() {
696 }
701
702 #[test]
703 fn test_commit_parses_signature_from_stdout() {
704 let sample = "Created revision a1b2c3d4 (#42)";
708 let signature = sample
709 .strip_prefix("Created revision ")
710 .and_then(|s| s.split_whitespace().next())
711 .unwrap_or(sample);
712 assert_eq!(signature, "a1b2c3d4");
713 }
714
715 #[test]
718 fn test_commit_info_from_lore_revision() {
719 let info = CommitInfo::from_lore_revision(
720 "sig123",
721 Some("sig122"),
722 "alice",
723 "feat: add manifest",
724 "2026-06-30T12:00:00Z",
725 );
726 assert_eq!(info.id, "sig123");
727 assert_eq!(info.parent.as_deref(), Some("sig122"));
728 assert_eq!(info.author, "alice");
729 assert_eq!(info.message, "feat: add manifest");
730 assert_eq!(info.timestamp, "2026-06-30T12:00:00Z");
731 }
732
733 #[test]
734 fn test_commit_info_default_timestamp() {
735 let info = CommitInfo::from_lore_revision("sig", None, "bob", "msg", "");
737 assert!(
738 info.timestamp.contains('T') || info.timestamp.contains('Z'),
739 "expected RFC 3339 timestamp, got: {}",
740 info.timestamp
741 );
742 }
743}