1use std::collections::BTreeMap;
34use std::path::{Path, PathBuf};
35use std::process::Command;
36use std::sync::atomic::{AtomicU64, Ordering};
37use std::time::Instant;
38
39use crate::error::NapError;
40use crate::vcs::{CommitInfo, VcsBackend};
41
42#[derive(serde::Deserialize)]
44struct ProviderConfigToml {
45 provider_type: String,
46 remote_url: Option<String>,
47 workspace_id: Option<String>,
48}
49
50const PORTALS_CLOUD_URL: &str = "grpcs://lore.portals.works";
52
53pub struct LoreProcessRunner;
70
71impl LoreProcessRunner {
72 pub fn binary() -> String {
75 std::env::var("NAPLORE_CLI").unwrap_or_else(|_| "lore".to_string())
76 }
77
78 pub fn run<I, S>(args: I, cwd: Option<&Path>) -> Result<String, NapError>
82 where
83 I: IntoIterator<Item = S>,
84 S: AsRef<std::ffi::OsStr>,
85 {
86 let args_vec: Vec<String> = args
87 .into_iter()
88 .map(|s| s.as_ref().to_string_lossy().into_owned())
89 .collect();
90 let bin = Self::binary();
91 let mut cmd = Command::new(&bin);
92 cmd.args(&args_vec);
93
94 if let Some(dir) = cwd {
95 cmd.current_dir(dir);
96 }
97
98 let start = Instant::now();
99 let output = cmd.output().map_err(|e| {
101 NapError::VcsError(format!(
102 "failed to execute `{}`: {}. Is `{}` installed and on $PATH?",
103 bin, e, bin
104 ))
105 })?;
106 let duration = start.elapsed();
107 if duration > std::time::Duration::from_secs(5) {
108 tracing::warn!(
109 duration_ms = duration.as_millis(),
110 command = format!("{} {:?}", bin, args_vec),
111 "lore command took > 5s — check Lore server health"
112 );
113 }
114
115 if output.status.success() {
116 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
117 return Ok(stdout);
118 }
119
120 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
122 let exit_code = output.status.code().unwrap_or(-1);
123
124 let nap_err = match exit_code {
128 1 => {
129 if stderr.contains("not authenticated")
131 || stderr.contains("authentication required")
132 || stderr.contains("Unauthenticated")
133 {
134 NapError::VcsError(
135 "Portals Cloud authentication is required; run `nap auth login` in an interactive terminal and retry"
136 .to_string(),
137 )
138 } else if stderr.contains("not a lore workspace")
139 || stderr.contains("not an initialised lore workspace")
140 {
141 NapError::VcsError(format!(
142 "not a lore workspace at {:?}",
143 cwd.unwrap_or(Path::new("."))
144 ))
145 } else if stderr.contains("not found") {
146 NapError::VcsError(format!("path not found in lore workspace: {}", stderr))
147 } else {
148 NapError::VcsError(format!(
149 "lore CLI exited with code {}: {}",
150 exit_code, stderr
151 ))
152 }
153 }
154 64..=126 => {
155 NapError::VcsError(format!(
157 "lore CLI configuration error ({}): {}",
158 exit_code, stderr
159 ))
160 }
161 _ => NapError::VcsError(format!(
162 "lore CLI exited with code {}: {}",
163 exit_code, stderr
164 )),
165 };
166
167 Err(nap_err)
168 }
169}
170
171static TEMP_BLOB_COUNTER: AtomicU64 = AtomicU64::new(0);
172
173fn temp_lore_output_path(prefix: &str) -> PathBuf {
174 let unique = TEMP_BLOB_COUNTER.fetch_add(1, Ordering::SeqCst);
175 std::env::temp_dir().join(format!(
176 "nap-{prefix}-{}-{}.tmp",
177 std::process::id(),
178 unique
179 ))
180}
181
182fn parse_metadata_output(stdout: &str) -> Result<BTreeMap<String, String>, String> {
183 if let Ok(value) = serde_json::from_str::<serde_json::Value>(stdout) {
184 let mut metadata = BTreeMap::new();
185 if let serde_json::Value::Object(map) = value {
186 for (key, value) in map {
187 let rendered = match value {
188 serde_json::Value::String(s) => s,
189 serde_json::Value::Bool(b) => b.to_string(),
190 serde_json::Value::Number(n) => n.to_string(),
191 serde_json::Value::Null => continue,
192 other => serde_json::to_string(&other).map_err(|e| e.to_string())?,
193 };
194 metadata.insert(key, rendered);
195 }
196 }
197 return Ok(metadata);
198 }
199
200 let mut metadata = BTreeMap::new();
201 for line in stdout.lines() {
202 let trimmed = line.trim();
203 if trimmed.is_empty() {
204 continue;
205 }
206 if let Some((key, value)) = trimmed.split_once('=').or_else(|| trimmed.split_once(':')) {
207 let key = key.trim();
208 if !key.is_empty() {
209 metadata.insert(key.to_string(), value.trim().to_string());
210 }
211 }
212 }
213 Ok(metadata)
214}
215
216#[derive(Debug, Clone)]
229pub struct LoreBackend {
230 remote_url: String,
232 workspace_id: String,
234}
235
236impl LoreBackend {
237 pub fn new(remote_url: &str, workspace_id: &str) -> Self {
242 Self {
243 remote_url: remote_url.to_string(),
244 workspace_id: workspace_id.to_string(),
245 }
246 }
247
248 pub fn remote_url(&self) -> &str {
249 &self.remote_url
250 }
251
252 pub fn clone_repo(url: &str, dest: &Path) -> Result<(), NapError> {
258 LoreProcessRunner::run(
259 [
260 "clone",
261 url,
262 dest.to_str().unwrap_or("."),
263 "--non-interactive",
264 ],
265 None,
266 )?;
267 Ok(())
268 }
269
270 pub fn from_env() -> Self {
283 if let Ok(nap_dir) = std::env::var("NAP_DIR") {
285 let manager = crate::server::manager::ServerManager::new(Path::new(&nap_dir));
286 let _ = tokio::runtime::Handle::try_current().map(|handle| {
287 handle.block_on(async {
288 let _ = manager.ensure_running().await;
289 });
290 });
291 }
292
293 let url_from_env = std::env::var("NAP_LORE_URL_BASE").ok();
295 let workspace_from_env = std::env::var("NAP_WORKSPACE_ID").ok();
296
297 if url_from_env.is_some() || workspace_from_env.is_some() {
298 let base = url_from_env.unwrap_or_else(|| "lore://localhost:41337".to_string());
299 let workspace_id = workspace_from_env.unwrap_or_else(|| "default".to_string());
300 tracing::debug!(
301 url_base = %base,
302 workspace_id = %workspace_id,
303 "LoreBackend::from_env using environment variables (override)"
304 );
305 return Self {
306 remote_url: base,
307 workspace_id,
308 };
309 }
310
311 if let Ok(base_dir_str) = std::env::var("NAP_INIT_BASE_DIR") {
315 let base_path = PathBuf::from(&base_dir_str);
316 let provider_config_path = base_path.join("provider.toml");
317 if provider_config_path.exists()
318 && let Ok(config_content) = std::fs::read_to_string(&provider_config_path)
319 && let Ok(config) = toml::from_str::<ProviderConfigToml>(&config_content)
320 {
321 match config.provider_type.as_str() {
322 "local" => {
323 tracing::debug!(
324 url_base = "lore://localhost:41337",
325 workspace_id = "default",
326 "LoreBackend::from_env using local provider from NAP_INIT_BASE_DIR"
327 );
328 return Self {
329 remote_url: "lore://localhost:41337".to_string(),
330 workspace_id: "default".to_string(),
331 };
332 }
333 "remote" => {
334 if let (Some(url), Some(workspace)) =
335 (config.remote_url, config.workspace_id)
336 {
337 tracing::debug!(
338 url_base = %url,
339 workspace_id = %workspace,
340 "LoreBackend::from_env using remote provider from NAP_INIT_BASE_DIR"
341 );
342 return Self {
343 remote_url: url,
344 workspace_id: workspace,
345 };
346 }
347 }
348 "portals-cloud" => {
349 let workspace_id =
350 config.workspace_id.unwrap_or_else(|| "default".to_string());
351 tracing::debug!(
352 url_base = %PORTALS_CLOUD_URL,
353 workspace_id = %workspace_id,
354 "LoreBackend::from_env using portals-cloud provider from NAP_INIT_BASE_DIR"
355 );
356 return Self {
357 remote_url: PORTALS_CLOUD_URL.to_string(),
358 workspace_id,
359 };
360 }
361 _ => {}
362 }
363 }
364 }
365
366 let nap_dir = if let Ok(nap_dir_str) = std::env::var("NAP_DIR") {
368 let path = PathBuf::from(&nap_dir_str);
370 if let Some(s) = path.to_str() {
371 if let Some(stripped) = s.strip_prefix('~') {
372 let home = std::env::var("HOME")
373 .or_else(|_| std::env::var("USERPROFILE"))
374 .unwrap_or_else(|_| ".".to_string());
375 PathBuf::from(home).join(stripped.trim_start_matches('/'))
376 } else {
377 path
378 }
379 } else {
380 path
381 }
382 } else {
383 let home = std::env::var("HOME")
385 .or_else(|_| std::env::var("USERPROFILE"))
386 .unwrap_or_else(|_| ".".to_string());
387 PathBuf::from(home).join(".nap")
388 };
389
390 let provider_config_path = nap_dir.join("provider.toml");
391 if provider_config_path.exists()
392 && let Ok(config_content) = std::fs::read_to_string(&provider_config_path)
393 && let Ok(config) = toml::from_str::<ProviderConfigToml>(&config_content)
394 {
395 match config.provider_type.as_str() {
396 "local" => {
397 tracing::debug!(
399 url_base = "lore://localhost:41337",
400 workspace_id = "default",
401 "LoreBackend::from_env using local provider configuration"
402 );
403 return Self {
404 remote_url: "lore://localhost:41337".to_string(),
405 workspace_id: "default".to_string(),
406 };
407 }
408 "remote" => {
409 if let (Some(url), Some(workspace)) = (config.remote_url, config.workspace_id) {
411 tracing::debug!(
412 url_base = %url,
413 workspace_id = %workspace,
414 "LoreBackend::from_env using remote provider configuration"
415 );
416 return Self {
417 remote_url: url,
418 workspace_id: workspace,
419 };
420 }
421 }
422 "portals-cloud" => {
423 let workspace_id = config.workspace_id.unwrap_or_else(|| "default".to_string());
425 tracing::debug!(
426 url_base = %PORTALS_CLOUD_URL,
427 workspace_id = %workspace_id,
428 "LoreBackend::from_env using portals-cloud provider configuration"
429 );
430 return Self {
431 remote_url: PORTALS_CLOUD_URL.to_string(),
432 workspace_id,
433 };
434 }
435 _ => {
436 tracing::debug!(
437 provider_type = %config.provider_type,
438 "Unknown provider type, falling back to defaults"
439 );
440 }
441 }
442 }
443
444 let base = "lore://localhost:41337".to_string();
446 let workspace_id = "default".to_string();
447 tracing::debug!(
448 url_base = %base,
449 workspace_id = %workspace_id,
450 "LoreBackend::from_env using defaults"
451 );
452 Self {
453 remote_url: base,
454 workspace_id,
455 }
456 }
457
458 pub fn from_provider(url_base: &str, workspace_id: &str) -> Self {
462 tracing::debug!(
463 url_base = %url_base,
464 workspace_id = %workspace_id,
465 "Creating LoreBackend from provider configuration"
466 );
467
468 Self {
469 remote_url: url_base.to_string(),
470 workspace_id: workspace_id.to_string(),
471 }
472 }
473
474 fn repo_url(&self, repo_id: &str) -> String {
476 format!("{}/{}", self.remote_url.trim_end_matches('/'), repo_id)
477 }
478}
479
480impl VcsBackend for LoreBackend {
481 fn remote_url_base(&self) -> Result<String, NapError> {
483 Ok(self.remote_url.clone())
484 }
485
486 fn init(&self, path: &Path) -> Result<(), NapError> {
488 let raw_id = path
497 .file_name()
498 .and_then(|n| n.to_str())
499 .unwrap_or("nap-repo");
500 let repo_id = {
509 let from_manifest = path
510 .join("repository.yaml")
511 .exists()
512 .then(|| {
513 std::fs::read_to_string(path.join("repository.yaml"))
514 .ok()
515 .and_then(|c| {
516 serde_yaml::from_str::<serde_yaml::Value>(&c)
517 .ok()
518 .and_then(|v| {
519 v.get("id").and_then(|id| id.as_str()).and_then(|id_str| {
520 id_str.strip_prefix("nap://").and_then(|rest| {
522 rest.split('/').next().map(|s| s.to_string())
523 })
524 })
525 })
526 })
527 })
528 .flatten()
529 .filter(|s| {
530 !s.is_empty()
531 && s.chars()
532 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
533 });
534 from_manifest.unwrap_or_else(|| {
535 let sanitized = raw_id.trim_start_matches(['.', '_']);
536 if sanitized.is_empty()
537 || !sanitized
538 .chars()
539 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
540 {
541 "nap-repo".to_string()
542 } else {
543 sanitized.to_string()
544 }
545 })
546 };
547
548 let url = self.repo_url(&repo_id);
549 let path_str = path.to_str().unwrap_or(".");
550
551 let server_path = path
553 .parent()
554 .unwrap_or(path)
555 .join(".lore-server")
556 .join(repo_id);
557
558 LoreProcessRunner::run(
560 [
561 "repository",
562 "create",
563 &url,
564 "--id",
565 &self.workspace_id,
566 "--repository",
567 server_path.to_str().unwrap_or("."),
568 "--non-interactive",
569 ],
570 None,
571 )
572 .map_err(|e| {
573 NapError::VcsError(format!("failed to create lore repository '{}': {}", url, e))
574 })?;
575
576 LoreProcessRunner::run(["clone", &url, path_str, "--non-interactive"], None).map_err(
578 |e| {
579 NapError::VcsError(format!(
580 "failed to clone lore repository to {:?}: {}",
581 path, e
582 ))
583 },
584 )?;
585
586 Ok(())
587 }
588
589 fn commit(&self, path: &Path, message: &str, author: &str) -> Result<String, NapError> {
591 LoreProcessRunner::run(["stage", "--scan", ".", "--non-interactive"], Some(path))?;
594
595 let stdout = LoreProcessRunner::run(
597 [
598 "revision",
599 "commit",
600 message,
601 "--identity",
602 author,
603 "--non-interactive",
604 ],
605 Some(path),
606 )?;
607
608 let signature = stdout
611 .lines()
612 .find_map(|line| {
613 line.strip_prefix("Signature :")
614 .or_else(|| line.strip_prefix("Signature:"))
615 })
616 .map(|s| s.trim().to_string())
617 .unwrap_or_else(|| {
618 stdout
620 .lines()
621 .next()
622 .unwrap_or(&stdout)
623 .trim()
624 .strip_prefix("Created revision ")
625 .and_then(|s| s.split_whitespace().next())
626 .map(|s| s.to_string())
627 .unwrap_or_else(|| stdout.trim().to_string())
628 });
629
630 Ok(signature)
631 }
632
633 fn read_file_at_ref(
635 &self,
636 repo_path: &Path,
637 file_path: &str,
638 reference: Option<&str>,
639 ) -> Result<String, NapError> {
640 let Some(reference) = reference else {
641 let full_path = repo_path.join(file_path);
642 return std::fs::read_to_string(&full_path).map_err(|e| {
643 NapError::VcsError(format!("failed to read {}: {}", full_path.display(), e))
644 });
645 };
646
647 let output_path = temp_lore_output_path("file-at-ref");
648 let output = output_path.to_string_lossy().into_owned();
649 LoreProcessRunner::run(
650 [
651 "file",
652 "write",
653 "--path",
654 file_path,
655 "--revision",
656 reference,
657 "--output",
658 &output,
659 "--non-interactive",
660 ],
661 Some(repo_path),
662 )?;
663
664 let content = std::fs::read_to_string(&output_path).map_err(|e| {
665 NapError::VcsError(format!(
666 "failed to read {} at revision {} from {}: {}",
667 file_path,
668 reference,
669 output_path.display(),
670 e
671 ))
672 })?;
673 let _ = std::fs::remove_file(&output_path);
674 Ok(content)
675 }
676
677 fn file_metadata_at_ref(
679 &self,
680 repo_path: &Path,
681 file_path: &str,
682 reference: &str,
683 ) -> Result<Option<BTreeMap<String, String>>, NapError> {
684 let stdout = LoreProcessRunner::run(
685 [
686 "file",
687 "metadata",
688 "get",
689 file_path,
690 "--revision",
691 reference,
692 "--non-interactive",
693 ],
694 Some(repo_path),
695 )?;
696
697 if stdout.trim().is_empty() || stdout.trim() == "null" {
698 return Ok(None);
699 }
700
701 parse_metadata_output(&stdout)
702 .map(Some)
703 .map_err(|e| NapError::VcsError(format!("failed to parse lore file metadata: {e}")))
704 }
705
706 fn read_provenance_blob(&self, repo_path: &Path, address: &str) -> Result<String, NapError> {
707 let output_path = temp_lore_output_path("provenance-blob");
708 let output = output_path.to_string_lossy().into_owned();
709
710 LoreProcessRunner::run(
711 [
712 "file",
713 "write",
714 "--address",
715 address,
716 "--output",
717 &output,
718 "--non-interactive",
719 ],
720 Some(repo_path),
721 )?;
722
723 let content = std::fs::read_to_string(&output_path).map_err(|e| {
724 NapError::VcsError(format!(
725 "failed to read hydrated provenance blob {} from {}: {}",
726 address,
727 output_path.display(),
728 e
729 ))
730 })?;
731 let _ = std::fs::remove_file(&output_path);
732 Ok(content)
733 }
734
735 fn log(
737 &self,
738 path: &Path,
739 _file: Option<&str>,
740 limit: usize,
741 ) -> Result<Vec<CommitInfo>, NapError> {
742 let limit_str = limit.to_string();
743 let args = vec!["history", &limit_str, "--non-interactive"];
744
745 let stdout = LoreProcessRunner::run(&args, Some(path))?;
746
747 if stdout.trim().is_empty() {
748 return Ok(Vec::new());
749 }
750
751 let mut commits = Vec::new();
760 let mut current_signature = String::new();
761 let mut current_author = String::new();
762 let mut current_message = String::new();
763 let mut current_timestamp = String::new();
764 let mut current_parent: Option<String> = None;
765 let mut in_message = false;
766
767 for line in stdout.lines() {
768 let trimmed = line.trim();
769 if trimmed.starts_with("Signature :") || trimmed.starts_with("Signature:") {
770 if !current_signature.is_empty() {
772 commits.push(CommitInfo {
773 id: std::mem::take(&mut current_signature),
774 parent: current_parent.take(),
775 author: std::mem::take(&mut current_author),
776 message: std::mem::take(&mut current_message),
777 timestamp: std::mem::take(&mut current_timestamp),
778 });
779 }
780 current_signature = trimmed
781 .strip_prefix("Signature :")
782 .or_else(|| trimmed.strip_prefix("Signature:"))
783 .unwrap_or("")
784 .trim()
785 .to_string();
786 in_message = false;
787 } else if trimmed.starts_with("Date :") || trimmed.starts_with("Date:") {
788 current_timestamp = trimmed
789 .split_once(':')
790 .map(|(_, v)| v.trim().to_string())
791 .unwrap_or_default();
792 in_message = true;
793 } else if trimmed.starts_with("Creator :") || trimmed.starts_with("Creator:") {
794 current_author = trimmed
795 .split_once(':')
796 .map(|(_, v)| v.trim().to_string())
797 .unwrap_or_default();
798 in_message = false;
799 } else if trimmed.starts_with("Revision :")
800 || trimmed.starts_with("Revision:")
801 || trimmed.starts_with("Branch :")
802 || trimmed.starts_with("Branch:")
803 || trimmed.starts_with("Committer :")
804 || trimmed.starts_with("Committer:")
805 {
806 in_message = false;
807 } else if in_message {
808 if trimmed.is_empty() || trimmed == "Commit succeeded" {
809 in_message = false;
810 } else {
811 if !current_message.is_empty() {
812 current_message.push('\n');
813 }
814 current_message.push_str(trimmed);
815 }
816 }
817 }
818 if !current_signature.is_empty() {
820 commits.push(CommitInfo {
821 id: current_signature,
822 parent: current_parent,
823 author: current_author,
824 message: current_message,
825 timestamp: current_timestamp,
826 });
827 }
828
829 Ok(commits)
830 }
831
832 fn create_branch(&self, path: &Path, name: &str) -> Result<(), NapError> {
834 LoreProcessRunner::run(["branch", "create", name, "--non-interactive"], Some(path))?;
835 Ok(())
836 }
837
838 fn switch_branch(&self, path: &Path, name: &str) -> Result<(), NapError> {
839 LoreProcessRunner::run(["branch", "switch", name, "--non-interactive"], Some(path))?;
840 Ok(())
841 }
842
843 fn current_branch(&self, path: &Path) -> Result<String, NapError> {
844 let stdout = LoreProcessRunner::run(["branch", "show", "--non-interactive"], Some(path))?;
845 Ok(stdout.trim().to_string())
846 }
847
848 fn list_branches(&self, path: &Path) -> Result<Vec<String>, NapError> {
849 let stdout = LoreProcessRunner::run(["branch", "list", "--non-interactive"], Some(path))?;
850 if stdout.is_empty() {
851 return Ok(Vec::new());
852 }
853 let mut branches = Vec::new();
860 let mut in_local = false;
861 for line in stdout.lines() {
862 let trimmed = line.trim();
863 if trimmed.starts_with("Local branches") {
864 in_local = true;
865 continue;
866 }
867 if trimmed.starts_with("Remote branches") {
868 in_local = false;
869 continue;
870 }
871 if in_local && !trimmed.is_empty() {
872 let name = trimmed.strip_prefix("* ").unwrap_or(trimmed);
874 branches.push(name.to_string());
875 }
876 }
877 Ok(branches)
878 }
879
880 fn head_hash(&self, path: &Path) -> Result<String, NapError> {
882 let stdout = LoreProcessRunner::run(["history", "1", "--non-interactive"], Some(path))?;
883
884 if stdout.trim().is_empty() {
885 return Err(NapError::VcsError(
886 "no commits in lore workspace".to_string(),
887 ));
888 }
889
890 stdout
892 .lines()
893 .find_map(|line| {
894 line.trim()
895 .strip_prefix("Signature :")
896 .or_else(|| line.trim().strip_prefix("Signature:"))
897 })
898 .map(|s| s.trim().to_string())
899 .ok_or_else(|| {
900 NapError::VcsError(format!(
901 "failed to parse signature from lore history: {stdout}"
902 ))
903 })
904 }
905
906 fn revert(&self, path: &Path, commit_hash: &str) -> Result<String, NapError> {
907 let stdout = LoreProcessRunner::run(
908 ["revision", "revert", commit_hash, "--non-interactive"],
909 Some(path),
910 )?;
911 let signature = stdout
913 .trim()
914 .strip_prefix("Created revert revision ")
915 .unwrap_or(stdout.trim());
916 Ok(signature.to_string())
917 }
918
919 fn resolve_branch_head(&self, path: &Path, branch: &str) -> Result<String, NapError> {
920 let stdout = LoreProcessRunner::run(
921 ["history", "1", "--branch", branch, "--non-interactive"],
922 Some(path),
923 )?;
924
925 if stdout.trim().is_empty() {
926 return Err(NapError::VcsError(format!(
927 "no commits found on branch '{branch}'"
928 )));
929 }
930
931 stdout
933 .lines()
934 .find_map(|line| {
935 line.trim()
936 .strip_prefix("Signature :")
937 .or_else(|| line.trim().strip_prefix("Signature:"))
938 })
939 .map(|s| s.trim().to_string())
940 .ok_or_else(|| {
941 NapError::VcsError(format!(
942 "failed to parse signature from lore history on branch '{branch}': {stdout}"
943 ))
944 })
945 }
946
947 fn add_remote(&self, path: &Path, name: &str, url: &str) -> Result<(), NapError> {
951 let remotes_path = path.join(".lore").join("remotes.toml");
952 let mut map: std::collections::BTreeMap<String, String> = if remotes_path.exists() {
953 let content = std::fs::read_to_string(&remotes_path).unwrap_or_default();
954 toml::from_str(&content).unwrap_or_default()
955 } else {
956 std::collections::BTreeMap::new()
957 };
958 map.insert(name.to_string(), url.to_string());
959 if let Some(parent) = remotes_path.parent() {
960 std::fs::create_dir_all(parent).map_err(|e| NapError::VcsError(e.to_string()))?;
961 }
962 let content = toml::to_string(&map).map_err(|e| NapError::VcsError(e.to_string()))?;
963 std::fs::write(&remotes_path, content).map_err(|e| NapError::VcsError(e.to_string()))?;
964 Ok(())
965 }
966
967 fn remove_remote(&self, path: &Path, name: &str) -> Result<(), NapError> {
968 let remotes_path = path.join(".lore").join("remotes.toml");
969 if !remotes_path.exists() {
970 return Ok(());
971 }
972 let content = std::fs::read_to_string(&remotes_path).unwrap_or_default();
973 let mut map: std::collections::BTreeMap<String, String> =
974 toml::from_str(&content).unwrap_or_default();
975 map.remove(name);
976 let new_content = toml::to_string(&map).map_err(|e| NapError::VcsError(e.to_string()))?;
977 std::fs::write(&remotes_path, new_content)
978 .map_err(|e| NapError::VcsError(e.to_string()))?;
979 Ok(())
980 }
981
982 fn list_remotes(&self, path: &Path) -> Result<Vec<(String, String)>, NapError> {
983 let remotes_path = path.join(".lore").join("remotes.toml");
984 if !remotes_path.exists() {
985 return Ok(Vec::new());
986 }
987 let content = std::fs::read_to_string(&remotes_path).unwrap_or_default();
988 let map: std::collections::BTreeMap<String, String> =
989 toml::from_str(&content).unwrap_or_default();
990 Ok(map.into_iter().collect())
991 }
992
993 fn push(
995 &self,
996 path: &Path,
997 _remote: Option<&str>,
998 branch: Option<&str>,
999 ) -> Result<(), NapError> {
1000 let branch_name = match branch {
1003 Some(b) => b.to_string(),
1004 None => self
1005 .current_branch(path)
1006 .unwrap_or_else(|_| "main".to_string()),
1007 };
1008
1009 let args = vec![
1011 "branch",
1012 "push",
1013 &branch_name,
1014 "--fast-forward-merge",
1015 "--non-interactive",
1016 ];
1017 LoreProcessRunner::run(&args, Some(path))?;
1018
1019 Ok(())
1020 }
1021
1022 fn pull(
1023 &self,
1024 path: &Path,
1025 _remote: Option<&str>,
1026 _branch: Option<&str>,
1027 ) -> Result<(), NapError> {
1028 let args = vec!["sync", "--non-interactive", "--reset"];
1030 LoreProcessRunner::run(&args, Some(path))?;
1031
1032 Ok(())
1033 }
1034}
1035
1036#[cfg(all(test, feature = "lore-integration"))]
1041mod tests {
1042 use super::*;
1043
1044 #[test]
1047 fn test_binary_default() {
1048 assert_eq!(LoreProcessRunner::binary(), "lore");
1049 }
1050
1051 #[test]
1052 fn test_binary_from_env() {
1053 temp_env::with_var("NAPLORE_CLI", Some("/custom/lore"), || {
1054 assert_eq!(LoreProcessRunner::binary(), "/custom/lore");
1055 });
1056 }
1057
1058 #[test]
1059 fn test_run_captures_stdout() {
1060 temp_env::with_var("NAPLORE_CLI", Some("lore-nonexistent-binary-12345"), || {
1064 let result = LoreProcessRunner::run(["--version"], None);
1065 assert!(result.is_err());
1066 let err = result.unwrap_err().to_string();
1067 assert!(
1068 err.contains("lore-nonexistent-binary-12345"),
1069 "error: {}",
1070 err
1071 );
1072 });
1073 }
1074
1075 #[test]
1078 fn test_new_and_from_env() {
1079 let backend = LoreBackend::new("lore://myhost:8700", "test-workspace");
1080 assert_eq!(backend.remote_url, "lore://myhost:8700");
1081 assert_eq!(backend.workspace_id, "test-workspace");
1082
1083 temp_env::with_vars(
1084 vec![
1085 ("NAP_LORE_URL_BASE", Some("lore://custom:9999")),
1086 ("NAP_WORKSPACE_ID", Some("custom-ws")),
1087 ],
1088 || {
1089 let from_env = LoreBackend::from_env();
1090 assert_eq!(from_env.remote_url, "lore://custom:9999");
1091 assert_eq!(from_env.workspace_id, "custom-ws");
1092 },
1093 );
1094 }
1095
1096 #[test]
1097 fn test_from_env_default_without_env_vars() {
1098 let temp_dir = tempfile::TempDir::new().unwrap();
1100 let nap_dir_str = temp_dir.path().to_str().unwrap();
1101
1102 temp_env::with_vars(
1103 vec![
1104 ("NAP_LORE_URL_BASE", None::<&str>),
1105 ("NAP_WORKSPACE_ID", None::<&str>),
1106 ("NAP_DIR", Some(nap_dir_str)),
1107 ],
1108 || {
1109 let backend = LoreBackend::from_env();
1110 assert_eq!(backend.remote_url, "lore://localhost:41337");
1111 assert_eq!(backend.workspace_id, "default");
1112 },
1113 );
1114 }
1115
1116 #[test]
1117 fn test_from_env_env_var_override() {
1118 let temp_dir = tempfile::TempDir::new().unwrap();
1120 let nap_dir_str = temp_dir.path().to_str().unwrap();
1121
1122 temp_env::with_vars(
1123 vec![
1124 ("NAP_LORE_URL_BASE", Some("lore://override:1234")),
1125 ("NAP_WORKSPACE_ID", Some("override-ws")),
1126 ("NAP_DIR", Some(nap_dir_str)),
1127 ],
1128 || {
1129 let backend = LoreBackend::from_env();
1130 assert_eq!(backend.remote_url, "lore://override:1234");
1131 assert_eq!(backend.workspace_id, "override-ws");
1132 },
1133 );
1134 }
1135
1136 #[test]
1137 fn test_from_env_partial_env_override() {
1138 let temp_dir = tempfile::TempDir::new().unwrap();
1140 let nap_dir_str = temp_dir.path().to_str().unwrap();
1141
1142 temp_env::with_vars(
1143 vec![
1144 ("NAP_LORE_URL_BASE", Some("lore://partial:5678")),
1145 ("NAP_WORKSPACE_ID", None::<&str>),
1146 ("NAP_DIR", Some(nap_dir_str)),
1147 ],
1148 || {
1149 let backend = LoreBackend::from_env();
1150 assert_eq!(backend.remote_url, "lore://partial:5678");
1151 assert_eq!(backend.workspace_id, "default");
1152 },
1153 );
1154 }
1155
1156 #[test]
1157 fn test_from_env_provider_config() {
1158 let temp_dir = tempfile::TempDir::new().unwrap();
1160 let provider_config = temp_dir.path().join("provider.toml");
1161 std::fs::write(
1162 &provider_config,
1163 r#"
1164provider_type = "remote"
1165remote_url = "lore://provider:9999"
1166workspace_id = "provider-ws"
1167"#,
1168 )
1169 .unwrap();
1170
1171 let nap_dir_str = temp_dir.path().to_str().unwrap();
1172 temp_env::with_vars(
1173 vec![
1174 ("NAP_LORE_URL_BASE", None::<&str>),
1175 ("NAP_WORKSPACE_ID", None::<&str>),
1176 ("NAP_DIR", Some(nap_dir_str)),
1177 ],
1178 || {
1179 let backend = LoreBackend::from_env();
1180 assert_eq!(backend.remote_url, "lore://provider:9999");
1181 assert_eq!(backend.workspace_id, "provider-ws");
1182 },
1183 );
1184 }
1185
1186 #[test]
1187 fn test_from_env_nap_dir_with_tilde() {
1188 let _home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
1190 let temp_dir = tempfile::TempDir::new().unwrap();
1191 let nap_dir_str = temp_dir.path().to_str().unwrap();
1192
1193 temp_env::with_vars(
1194 vec![
1195 ("NAP_LORE_URL_BASE", None::<&str>),
1196 ("NAP_WORKSPACE_ID", None::<&str>),
1197 ("NAP_DIR", Some(nap_dir_str)),
1198 ],
1199 || {
1200 let backend = LoreBackend::from_env();
1201 assert_eq!(backend.remote_url, "lore://localhost:41337");
1203 assert_eq!(backend.workspace_id, "default");
1204 },
1205 );
1206 }
1207
1208 #[test]
1209 fn test_from_env_local_provider_config() {
1210 let temp_dir = tempfile::TempDir::new().unwrap();
1212 let provider_config = temp_dir.path().join("provider.toml");
1213 std::fs::write(
1214 &provider_config,
1215 r#"
1216provider_type = "local"
1217"#,
1218 )
1219 .unwrap();
1220
1221 let nap_dir_str = temp_dir.path().to_str().unwrap();
1222 temp_env::with_vars(
1223 vec![
1224 ("NAP_LORE_URL_BASE", None::<&str>),
1225 ("NAP_WORKSPACE_ID", None::<&str>),
1226 ("NAP_DIR", Some(nap_dir_str)),
1227 ],
1228 || {
1229 let backend = LoreBackend::from_env();
1230 assert_eq!(backend.remote_url, "lore://localhost:41337");
1231 assert_eq!(backend.workspace_id, "default");
1232 },
1233 );
1234 }
1235
1236 #[test]
1237 fn test_from_env_portals_cloud_provider_config() {
1238 let temp_dir = tempfile::TempDir::new().unwrap();
1240 let provider_config = temp_dir.path().join("provider.toml");
1241 std::fs::write(
1242 &provider_config,
1243 r#"
1244provider_type = "portals-cloud"
1245workspace_id = "cloud-ws"
1246"#,
1247 )
1248 .unwrap();
1249
1250 let nap_dir_str = temp_dir.path().to_str().unwrap();
1251 temp_env::with_vars(
1252 vec![
1253 ("NAP_LORE_URL_BASE", None::<&str>),
1254 ("NAP_WORKSPACE_ID", None::<&str>),
1255 ("NAP_DIR", Some(nap_dir_str)),
1256 ],
1257 || {
1258 let backend = LoreBackend::from_env();
1259 assert_eq!(backend.remote_url, PORTALS_CLOUD_URL);
1260 assert_eq!(backend.workspace_id, "cloud-ws");
1261 },
1262 );
1263 }
1264
1265 #[test]
1266 fn test_from_env_portals_cloud_default_workspace() {
1267 let temp_dir = tempfile::TempDir::new().unwrap();
1269 let provider_config = temp_dir.path().join("provider.toml");
1270 std::fs::write(
1271 &provider_config,
1272 r#"
1273provider_type = "portals-cloud"
1274"#,
1275 )
1276 .unwrap();
1277
1278 let nap_dir_str = temp_dir.path().to_str().unwrap();
1279 temp_env::with_vars(
1280 vec![
1281 ("NAP_LORE_URL_BASE", None::<&str>),
1282 ("NAP_WORKSPACE_ID", None::<&str>),
1283 ("NAP_DIR", Some(nap_dir_str)),
1284 ],
1285 || {
1286 let backend = LoreBackend::from_env();
1287 assert_eq!(backend.remote_url, PORTALS_CLOUD_URL);
1288 assert_eq!(backend.workspace_id, "default");
1289 },
1290 );
1291 }
1292
1293 #[test]
1294 fn test_from_env_unknown_provider_type() {
1295 let temp_dir = tempfile::TempDir::new().unwrap();
1297 let provider_config = temp_dir.path().join("provider.toml");
1298 std::fs::write(
1299 &provider_config,
1300 r#"
1301provider_type = "unknown-provider"
1302"#,
1303 )
1304 .unwrap();
1305
1306 let nap_dir_str = temp_dir.path().to_str().unwrap();
1307 temp_env::with_vars(
1308 vec![
1309 ("NAP_LORE_URL_BASE", None::<&str>),
1310 ("NAP_WORKSPACE_ID", None::<&str>),
1311 ("NAP_DIR", Some(nap_dir_str)),
1312 ],
1313 || {
1314 let backend = LoreBackend::from_env();
1315 assert_eq!(backend.remote_url, "lore://localhost:41337");
1316 assert_eq!(backend.workspace_id, "default");
1317 },
1318 );
1319 }
1320
1321 #[test]
1322 fn test_repo_url_joining() {
1323 let backend = LoreBackend::new("lore://localhost:8700", "ws");
1324 assert_eq!(backend.repo_url("my-repo"), "lore://localhost:8700/my-repo");
1325
1326 let backend2 = LoreBackend::new("lore://host:8700/", "ws");
1328 assert_eq!(backend2.repo_url("foo"), "lore://host:8700/foo");
1329 }
1330
1331 #[test]
1332 fn test_list_branches_empty_json() {
1333 }
1338
1339 #[test]
1340 fn test_commit_parses_signature_from_stdout() {
1341 let sample = "Created revision a1b2c3d4 (#42)";
1345 let signature = sample
1346 .strip_prefix("Created revision ")
1347 .and_then(|s| s.split_whitespace().next())
1348 .unwrap_or(sample);
1349 assert_eq!(signature, "a1b2c3d4");
1350 }
1351
1352 #[test]
1355 fn test_commit_info_from_lore_revision() {
1356 let info = CommitInfo::from_lore_revision(
1357 "sig123",
1358 Some("sig122"),
1359 "alice",
1360 "feat: add manifest",
1361 "2026-06-30T12:00:00Z",
1362 );
1363 assert_eq!(info.id, "sig123");
1364 assert_eq!(info.parent.as_deref(), Some("sig122"));
1365 assert_eq!(info.author, "alice");
1366 assert_eq!(info.message, "feat: add manifest");
1367 assert_eq!(info.timestamp, "2026-06-30T12:00:00Z");
1368 }
1369
1370 #[test]
1371 fn test_commit_info_default_timestamp() {
1372 let info = CommitInfo::from_lore_revision("sig", None, "bob", "msg", "");
1374 assert!(
1375 info.timestamp.contains('T') || info.timestamp.contains('Z'),
1376 "expected RFC 3339 timestamp, got: {}",
1377 info.timestamp
1378 );
1379 }
1380}