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.sh";
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 let nap_dir = if let Ok(nap_dir_str) = std::env::var("NAP_DIR") {
313 let path = PathBuf::from(&nap_dir_str);
315 if let Some(s) = path.to_str() {
316 if let Some(stripped) = s.strip_prefix('~') {
317 let home = std::env::var("HOME")
318 .or_else(|_| std::env::var("USERPROFILE"))
319 .unwrap_or_else(|_| ".".to_string());
320 PathBuf::from(home).join(stripped.trim_start_matches('/'))
321 } else {
322 path
323 }
324 } else {
325 path
326 }
327 } else {
328 let home = std::env::var("HOME")
330 .or_else(|_| std::env::var("USERPROFILE"))
331 .unwrap_or_else(|_| ".".to_string());
332 PathBuf::from(home).join(".nap")
333 };
334
335 let provider_config_path = nap_dir.join("provider.toml");
336 if provider_config_path.exists()
337 && let Ok(config_content) = std::fs::read_to_string(&provider_config_path)
338 && let Ok(config) = toml::from_str::<ProviderConfigToml>(&config_content)
339 {
340 match config.provider_type.as_str() {
341 "local" => {
342 tracing::debug!(
344 url_base = "lore://localhost:41337",
345 workspace_id = "default",
346 "LoreBackend::from_env using local provider configuration"
347 );
348 return Self {
349 remote_url: "lore://localhost:41337".to_string(),
350 workspace_id: "default".to_string(),
351 };
352 }
353 "remote" => {
354 if let (Some(url), Some(workspace)) = (config.remote_url, config.workspace_id) {
356 tracing::debug!(
357 url_base = %url,
358 workspace_id = %workspace,
359 "LoreBackend::from_env using remote provider configuration"
360 );
361 return Self {
362 remote_url: url,
363 workspace_id: workspace,
364 };
365 }
366 }
367 "portals-cloud" => {
368 let workspace_id = config.workspace_id.unwrap_or_else(|| "default".to_string());
370 tracing::debug!(
371 url_base = %PORTALS_CLOUD_URL,
372 workspace_id = %workspace_id,
373 "LoreBackend::from_env using portals-cloud provider configuration"
374 );
375 return Self {
376 remote_url: PORTALS_CLOUD_URL.to_string(),
377 workspace_id,
378 };
379 }
380 _ => {
381 tracing::debug!(
382 provider_type = %config.provider_type,
383 "Unknown provider type, falling back to defaults"
384 );
385 }
386 }
387 }
388
389 let base = "lore://localhost:41337".to_string();
391 let workspace_id = "default".to_string();
392 tracing::debug!(
393 url_base = %base,
394 workspace_id = %workspace_id,
395 "LoreBackend::from_env using defaults"
396 );
397 Self {
398 remote_url: base,
399 workspace_id,
400 }
401 }
402
403 pub fn from_provider(url_base: &str, workspace_id: &str) -> Self {
407 tracing::debug!(
408 url_base = %url_base,
409 workspace_id = %workspace_id,
410 "Creating LoreBackend from provider configuration"
411 );
412
413 Self {
414 remote_url: url_base.to_string(),
415 workspace_id: workspace_id.to_string(),
416 }
417 }
418
419 fn repo_url(&self, repo_id: &str) -> String {
421 format!("{}/{}", self.remote_url.trim_end_matches('/'), repo_id)
422 }
423}
424
425impl VcsBackend for LoreBackend {
426 fn remote_url_base(&self) -> Result<String, NapError> {
428 Ok(self.remote_url.clone())
429 }
430
431 fn init(&self, path: &Path) -> Result<(), NapError> {
433 let repo_id = path
442 .file_name()
443 .and_then(|n| n.to_str())
444 .unwrap_or("nap-repo");
445
446 let url = self.repo_url(repo_id);
447 let path_str = path.to_str().unwrap_or(".");
448
449 let server_path = path
451 .parent()
452 .unwrap_or(path)
453 .join(".lore-server")
454 .join(repo_id);
455
456 LoreProcessRunner::run(
458 [
459 "repository",
460 "create",
461 &url,
462 "--id",
463 &self.workspace_id,
464 "--repository",
465 server_path.to_str().unwrap_or("."),
466 "--non-interactive",
467 ],
468 None,
469 )
470 .map_err(|e| {
471 NapError::VcsError(format!("failed to create lore repository '{}': {}", url, e))
472 })?;
473
474 LoreProcessRunner::run(["clone", &url, path_str, "--non-interactive"], None).map_err(
476 |e| {
477 NapError::VcsError(format!(
478 "failed to clone lore repository to {:?}: {}",
479 path, e
480 ))
481 },
482 )?;
483
484 Ok(())
485 }
486
487 fn commit(&self, path: &Path, message: &str, author: &str) -> Result<String, NapError> {
489 LoreProcessRunner::run(["stage", "--scan", ".", "--non-interactive"], Some(path))?;
492
493 let stdout = LoreProcessRunner::run(
495 [
496 "revision",
497 "commit",
498 message,
499 "--identity",
500 author,
501 "--non-interactive",
502 ],
503 Some(path),
504 )?;
505
506 let signature = stdout
509 .lines()
510 .find_map(|line| {
511 line.strip_prefix("Signature :")
512 .or_else(|| line.strip_prefix("Signature:"))
513 })
514 .map(|s| s.trim().to_string())
515 .unwrap_or_else(|| {
516 stdout
518 .lines()
519 .next()
520 .unwrap_or(&stdout)
521 .trim()
522 .strip_prefix("Created revision ")
523 .and_then(|s| s.split_whitespace().next())
524 .map(|s| s.to_string())
525 .unwrap_or_else(|| stdout.trim().to_string())
526 });
527
528 Ok(signature)
529 }
530
531 fn read_file_at_ref(
533 &self,
534 repo_path: &Path,
535 file_path: &str,
536 reference: Option<&str>,
537 ) -> Result<String, NapError> {
538 let Some(reference) = reference else {
539 let full_path = repo_path.join(file_path);
540 return std::fs::read_to_string(&full_path).map_err(|e| {
541 NapError::VcsError(format!("failed to read {}: {}", full_path.display(), e))
542 });
543 };
544
545 let output_path = temp_lore_output_path("file-at-ref");
546 let output = output_path.to_string_lossy().into_owned();
547 LoreProcessRunner::run(
548 [
549 "file",
550 "write",
551 "--path",
552 file_path,
553 "--revision",
554 reference,
555 "--output",
556 &output,
557 "--non-interactive",
558 ],
559 Some(repo_path),
560 )?;
561
562 let content = std::fs::read_to_string(&output_path).map_err(|e| {
563 NapError::VcsError(format!(
564 "failed to read {} at revision {} from {}: {}",
565 file_path,
566 reference,
567 output_path.display(),
568 e
569 ))
570 })?;
571 let _ = std::fs::remove_file(&output_path);
572 Ok(content)
573 }
574
575 fn file_metadata_at_ref(
577 &self,
578 repo_path: &Path,
579 file_path: &str,
580 reference: &str,
581 ) -> Result<Option<BTreeMap<String, String>>, NapError> {
582 let stdout = LoreProcessRunner::run(
583 [
584 "file",
585 "metadata",
586 "get",
587 file_path,
588 "--revision",
589 reference,
590 "--non-interactive",
591 ],
592 Some(repo_path),
593 )?;
594
595 if stdout.trim().is_empty() || stdout.trim() == "null" {
596 return Ok(None);
597 }
598
599 parse_metadata_output(&stdout)
600 .map(Some)
601 .map_err(|e| NapError::VcsError(format!("failed to parse lore file metadata: {e}")))
602 }
603
604 fn read_provenance_blob(&self, repo_path: &Path, address: &str) -> Result<String, NapError> {
605 let output_path = temp_lore_output_path("provenance-blob");
606 let output = output_path.to_string_lossy().into_owned();
607
608 LoreProcessRunner::run(
609 [
610 "file",
611 "write",
612 "--address",
613 address,
614 "--output",
615 &output,
616 "--non-interactive",
617 ],
618 Some(repo_path),
619 )?;
620
621 let content = std::fs::read_to_string(&output_path).map_err(|e| {
622 NapError::VcsError(format!(
623 "failed to read hydrated provenance blob {} from {}: {}",
624 address,
625 output_path.display(),
626 e
627 ))
628 })?;
629 let _ = std::fs::remove_file(&output_path);
630 Ok(content)
631 }
632
633 fn log(
635 &self,
636 path: &Path,
637 _file: Option<&str>,
638 limit: usize,
639 ) -> Result<Vec<CommitInfo>, NapError> {
640 let limit_str = limit.to_string();
641 let args = vec!["history", &limit_str, "--non-interactive"];
642
643 let stdout = LoreProcessRunner::run(&args, Some(path))?;
644
645 if stdout.trim().is_empty() {
646 return Ok(Vec::new());
647 }
648
649 let mut commits = Vec::new();
658 let mut current_signature = String::new();
659 let mut current_author = String::new();
660 let mut current_message = String::new();
661 let mut current_timestamp = String::new();
662 let mut current_parent: Option<String> = None;
663 let mut in_message = false;
664
665 for line in stdout.lines() {
666 let trimmed = line.trim();
667 if trimmed.starts_with("Signature :") || trimmed.starts_with("Signature:") {
668 if !current_signature.is_empty() {
670 commits.push(CommitInfo {
671 id: std::mem::take(&mut current_signature),
672 parent: current_parent.take(),
673 author: std::mem::take(&mut current_author),
674 message: std::mem::take(&mut current_message),
675 timestamp: std::mem::take(&mut current_timestamp),
676 });
677 }
678 current_signature = trimmed
679 .strip_prefix("Signature :")
680 .or_else(|| trimmed.strip_prefix("Signature:"))
681 .unwrap_or("")
682 .trim()
683 .to_string();
684 in_message = false;
685 } else if trimmed.starts_with("Date :") || trimmed.starts_with("Date:") {
686 current_timestamp = trimmed
687 .split_once(':')
688 .map(|(_, v)| v.trim().to_string())
689 .unwrap_or_default();
690 in_message = true;
691 } else if trimmed.starts_with("Creator :") || trimmed.starts_with("Creator:") {
692 current_author = trimmed
693 .split_once(':')
694 .map(|(_, v)| v.trim().to_string())
695 .unwrap_or_default();
696 in_message = false;
697 } else if trimmed.starts_with("Revision :")
698 || trimmed.starts_with("Revision:")
699 || trimmed.starts_with("Branch :")
700 || trimmed.starts_with("Branch:")
701 || trimmed.starts_with("Committer :")
702 || trimmed.starts_with("Committer:")
703 {
704 in_message = false;
705 } else if in_message {
706 if trimmed.is_empty() || trimmed == "Commit succeeded" {
707 in_message = false;
708 } else {
709 if !current_message.is_empty() {
710 current_message.push('\n');
711 }
712 current_message.push_str(trimmed);
713 }
714 }
715 }
716 if !current_signature.is_empty() {
718 commits.push(CommitInfo {
719 id: current_signature,
720 parent: current_parent,
721 author: current_author,
722 message: current_message,
723 timestamp: current_timestamp,
724 });
725 }
726
727 Ok(commits)
728 }
729
730 fn create_branch(&self, path: &Path, name: &str) -> Result<(), NapError> {
732 LoreProcessRunner::run(["branch", "create", name, "--non-interactive"], Some(path))?;
733 Ok(())
734 }
735
736 fn switch_branch(&self, path: &Path, name: &str) -> Result<(), NapError> {
737 LoreProcessRunner::run(["branch", "switch", name, "--non-interactive"], Some(path))?;
738 Ok(())
739 }
740
741 fn current_branch(&self, path: &Path) -> Result<String, NapError> {
742 let stdout = LoreProcessRunner::run(["branch", "show", "--non-interactive"], Some(path))?;
743 Ok(stdout.trim().to_string())
744 }
745
746 fn list_branches(&self, path: &Path) -> Result<Vec<String>, NapError> {
747 let stdout = LoreProcessRunner::run(["branch", "list", "--non-interactive"], Some(path))?;
748 if stdout.is_empty() {
749 return Ok(Vec::new());
750 }
751 let mut branches = Vec::new();
758 let mut in_local = false;
759 for line in stdout.lines() {
760 let trimmed = line.trim();
761 if trimmed.starts_with("Local branches") {
762 in_local = true;
763 continue;
764 }
765 if trimmed.starts_with("Remote branches") {
766 in_local = false;
767 continue;
768 }
769 if in_local && !trimmed.is_empty() {
770 let name = trimmed.strip_prefix("* ").unwrap_or(trimmed);
772 branches.push(name.to_string());
773 }
774 }
775 Ok(branches)
776 }
777
778 fn head_hash(&self, path: &Path) -> Result<String, NapError> {
780 let stdout = LoreProcessRunner::run(["history", "1", "--non-interactive"], Some(path))?;
781
782 if stdout.trim().is_empty() {
783 return Err(NapError::VcsError(
784 "no commits in lore workspace".to_string(),
785 ));
786 }
787
788 stdout
790 .lines()
791 .find_map(|line| {
792 line.trim()
793 .strip_prefix("Signature :")
794 .or_else(|| line.trim().strip_prefix("Signature:"))
795 })
796 .map(|s| s.trim().to_string())
797 .ok_or_else(|| {
798 NapError::VcsError(format!(
799 "failed to parse signature from lore history: {stdout}"
800 ))
801 })
802 }
803
804 fn revert(&self, path: &Path, commit_hash: &str) -> Result<String, NapError> {
805 let stdout = LoreProcessRunner::run(
806 ["revision", "revert", commit_hash, "--non-interactive"],
807 Some(path),
808 )?;
809 let signature = stdout
811 .trim()
812 .strip_prefix("Created revert revision ")
813 .unwrap_or(stdout.trim());
814 Ok(signature.to_string())
815 }
816
817 fn resolve_branch_head(&self, path: &Path, branch: &str) -> Result<String, NapError> {
818 let stdout = LoreProcessRunner::run(
819 ["history", "1", "--branch", branch, "--non-interactive"],
820 Some(path),
821 )?;
822
823 if stdout.trim().is_empty() {
824 return Err(NapError::VcsError(format!(
825 "no commits found on branch '{branch}'"
826 )));
827 }
828
829 stdout
831 .lines()
832 .find_map(|line| {
833 line.trim()
834 .strip_prefix("Signature :")
835 .or_else(|| line.trim().strip_prefix("Signature:"))
836 })
837 .map(|s| s.trim().to_string())
838 .ok_or_else(|| {
839 NapError::VcsError(format!(
840 "failed to parse signature from lore history on branch '{branch}': {stdout}"
841 ))
842 })
843 }
844
845 fn add_remote(&self, path: &Path, name: &str, url: &str) -> Result<(), NapError> {
847 LoreProcessRunner::run(
848 [
849 "repository",
850 "add",
851 url,
852 "--alias",
853 name,
854 "--non-interactive",
855 ],
856 Some(path),
857 )?;
858 Ok(())
859 }
860
861 fn remove_remote(&self, path: &Path, name: &str) -> Result<(), NapError> {
862 LoreProcessRunner::run(
863 ["repository", "remove", "--alias", name, "--non-interactive"],
864 Some(path),
865 )?;
866 Ok(())
867 }
868
869 fn list_remotes(&self, path: &Path) -> Result<Vec<(String, String)>, NapError> {
870 let stdout = LoreProcessRunner::run(
871 [
872 "repository",
873 "list",
874 "--format",
875 "json",
876 "--non-interactive",
877 ],
878 Some(path),
879 )?;
880
881 if stdout.is_empty() || stdout == "[]" || stdout == "null" {
882 return Ok(Vec::new());
883 }
884
885 #[derive(serde::Deserialize)]
887 struct RemoteEntry {
888 #[allow(dead_code)]
889 name: String,
890 #[allow(dead_code)]
891 url: String,
892 }
893 let entries: Vec<RemoteEntry> = serde_json::from_str(&stdout).map_err(|e| {
894 NapError::VcsError(format!(
895 "failed to parse lore repository list JSON: {}. Raw: {}",
896 e, stdout
897 ))
898 })?;
899
900 let pairs: Vec<(String, String)> = entries.into_iter().map(|e| (e.name, e.url)).collect();
901 Ok(pairs)
902 }
903
904 fn push(
906 &self,
907 path: &Path,
908 _remote: Option<&str>,
909 branch: Option<&str>,
910 ) -> Result<(), NapError> {
911 let branch_name = match branch {
914 Some(b) => b.to_string(),
915 None => self
916 .current_branch(path)
917 .unwrap_or_else(|_| "main".to_string()),
918 };
919
920 let args = vec![
922 "branch",
923 "push",
924 &branch_name,
925 "--fast-forward-merge",
926 "--non-interactive",
927 ];
928 LoreProcessRunner::run(&args, Some(path))?;
929
930 Ok(())
931 }
932
933 fn pull(
934 &self,
935 path: &Path,
936 _remote: Option<&str>,
937 _branch: Option<&str>,
938 ) -> Result<(), NapError> {
939 let args = vec!["sync", "--non-interactive", "--reset"];
941 LoreProcessRunner::run(&args, Some(path))?;
942
943 Ok(())
944 }
945}
946
947#[cfg(all(test, feature = "lore-integration"))]
952mod tests {
953 use super::*;
954
955 #[test]
958 fn test_binary_default() {
959 assert_eq!(LoreProcessRunner::binary(), "lore");
960 }
961
962 #[test]
963 fn test_binary_from_env() {
964 temp_env::with_var("NAPLORE_CLI", Some("/custom/lore"), || {
965 assert_eq!(LoreProcessRunner::binary(), "/custom/lore");
966 });
967 }
968
969 #[test]
970 fn test_run_captures_stdout() {
971 temp_env::with_var("NAPLORE_CLI", Some("lore-nonexistent-binary-12345"), || {
975 let result = LoreProcessRunner::run(["--version"], None);
976 assert!(result.is_err());
977 let err = result.unwrap_err().to_string();
978 assert!(
979 err.contains("lore-nonexistent-binary-12345"),
980 "error: {}",
981 err
982 );
983 });
984 }
985
986 #[test]
989 fn test_new_and_from_env() {
990 let backend = LoreBackend::new("lore://myhost:8700", "test-workspace");
991 assert_eq!(backend.remote_url, "lore://myhost:8700");
992 assert_eq!(backend.workspace_id, "test-workspace");
993
994 temp_env::with_vars(
995 vec![
996 ("NAP_LORE_URL_BASE", Some("lore://custom:9999")),
997 ("NAP_WORKSPACE_ID", Some("custom-ws")),
998 ],
999 || {
1000 let from_env = LoreBackend::from_env();
1001 assert_eq!(from_env.remote_url, "lore://custom:9999");
1002 assert_eq!(from_env.workspace_id, "custom-ws");
1003 },
1004 );
1005 }
1006
1007 #[test]
1008 fn test_from_env_default_without_env_vars() {
1009 let temp_dir = tempfile::TempDir::new().unwrap();
1011 let nap_dir_str = temp_dir.path().to_str().unwrap();
1012
1013 temp_env::with_vars(
1014 vec![
1015 ("NAP_LORE_URL_BASE", None::<&str>),
1016 ("NAP_WORKSPACE_ID", None::<&str>),
1017 ("NAP_DIR", Some(nap_dir_str)),
1018 ],
1019 || {
1020 let backend = LoreBackend::from_env();
1021 assert_eq!(backend.remote_url, "lore://localhost:41337");
1022 assert_eq!(backend.workspace_id, "default");
1023 },
1024 );
1025 }
1026
1027 #[test]
1028 fn test_from_env_env_var_override() {
1029 let temp_dir = tempfile::TempDir::new().unwrap();
1031 let nap_dir_str = temp_dir.path().to_str().unwrap();
1032
1033 temp_env::with_vars(
1034 vec![
1035 ("NAP_LORE_URL_BASE", Some("lore://override:1234")),
1036 ("NAP_WORKSPACE_ID", Some("override-ws")),
1037 ("NAP_DIR", Some(nap_dir_str)),
1038 ],
1039 || {
1040 let backend = LoreBackend::from_env();
1041 assert_eq!(backend.remote_url, "lore://override:1234");
1042 assert_eq!(backend.workspace_id, "override-ws");
1043 },
1044 );
1045 }
1046
1047 #[test]
1048 fn test_from_env_partial_env_override() {
1049 let temp_dir = tempfile::TempDir::new().unwrap();
1051 let nap_dir_str = temp_dir.path().to_str().unwrap();
1052
1053 temp_env::with_vars(
1054 vec![
1055 ("NAP_LORE_URL_BASE", Some("lore://partial:5678")),
1056 ("NAP_WORKSPACE_ID", None::<&str>),
1057 ("NAP_DIR", Some(nap_dir_str)),
1058 ],
1059 || {
1060 let backend = LoreBackend::from_env();
1061 assert_eq!(backend.remote_url, "lore://partial:5678");
1062 assert_eq!(backend.workspace_id, "default");
1063 },
1064 );
1065 }
1066
1067 #[test]
1068 fn test_from_env_provider_config() {
1069 let temp_dir = tempfile::TempDir::new().unwrap();
1071 let provider_config = temp_dir.path().join("provider.toml");
1072 std::fs::write(
1073 &provider_config,
1074 r#"
1075provider_type = "remote"
1076remote_url = "lore://provider:9999"
1077workspace_id = "provider-ws"
1078"#,
1079 )
1080 .unwrap();
1081
1082 let nap_dir_str = temp_dir.path().to_str().unwrap();
1083 temp_env::with_vars(
1084 vec![
1085 ("NAP_LORE_URL_BASE", None::<&str>),
1086 ("NAP_WORKSPACE_ID", None::<&str>),
1087 ("NAP_DIR", Some(nap_dir_str)),
1088 ],
1089 || {
1090 let backend = LoreBackend::from_env();
1091 assert_eq!(backend.remote_url, "lore://provider:9999");
1092 assert_eq!(backend.workspace_id, "provider-ws");
1093 },
1094 );
1095 }
1096
1097 #[test]
1098 fn test_from_env_nap_dir_with_tilde() {
1099 let _home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
1101 let temp_dir = tempfile::TempDir::new().unwrap();
1102 let nap_dir_str = temp_dir.path().to_str().unwrap();
1103
1104 temp_env::with_vars(
1105 vec![
1106 ("NAP_LORE_URL_BASE", None::<&str>),
1107 ("NAP_WORKSPACE_ID", None::<&str>),
1108 ("NAP_DIR", Some(nap_dir_str)),
1109 ],
1110 || {
1111 let backend = LoreBackend::from_env();
1112 assert_eq!(backend.remote_url, "lore://localhost:41337");
1114 assert_eq!(backend.workspace_id, "default");
1115 },
1116 );
1117 }
1118
1119 #[test]
1120 fn test_from_env_local_provider_config() {
1121 let temp_dir = tempfile::TempDir::new().unwrap();
1123 let provider_config = temp_dir.path().join("provider.toml");
1124 std::fs::write(
1125 &provider_config,
1126 r#"
1127provider_type = "local"
1128"#,
1129 )
1130 .unwrap();
1131
1132 let nap_dir_str = temp_dir.path().to_str().unwrap();
1133 temp_env::with_vars(
1134 vec![
1135 ("NAP_LORE_URL_BASE", None::<&str>),
1136 ("NAP_WORKSPACE_ID", None::<&str>),
1137 ("NAP_DIR", Some(nap_dir_str)),
1138 ],
1139 || {
1140 let backend = LoreBackend::from_env();
1141 assert_eq!(backend.remote_url, "lore://localhost:41337");
1142 assert_eq!(backend.workspace_id, "default");
1143 },
1144 );
1145 }
1146
1147 #[test]
1148 fn test_from_env_portals_cloud_provider_config() {
1149 let temp_dir = tempfile::TempDir::new().unwrap();
1151 let provider_config = temp_dir.path().join("provider.toml");
1152 std::fs::write(
1153 &provider_config,
1154 r#"
1155provider_type = "portals-cloud"
1156workspace_id = "cloud-ws"
1157"#,
1158 )
1159 .unwrap();
1160
1161 let nap_dir_str = temp_dir.path().to_str().unwrap();
1162 temp_env::with_vars(
1163 vec![
1164 ("NAP_LORE_URL_BASE", None::<&str>),
1165 ("NAP_WORKSPACE_ID", None::<&str>),
1166 ("NAP_DIR", Some(nap_dir_str)),
1167 ],
1168 || {
1169 let backend = LoreBackend::from_env();
1170 assert_eq!(backend.remote_url, PORTALS_CLOUD_URL);
1171 assert_eq!(backend.workspace_id, "cloud-ws");
1172 },
1173 );
1174 }
1175
1176 #[test]
1177 fn test_from_env_portals_cloud_default_workspace() {
1178 let temp_dir = tempfile::TempDir::new().unwrap();
1180 let provider_config = temp_dir.path().join("provider.toml");
1181 std::fs::write(
1182 &provider_config,
1183 r#"
1184provider_type = "portals-cloud"
1185"#,
1186 )
1187 .unwrap();
1188
1189 let nap_dir_str = temp_dir.path().to_str().unwrap();
1190 temp_env::with_vars(
1191 vec![
1192 ("NAP_LORE_URL_BASE", None::<&str>),
1193 ("NAP_WORKSPACE_ID", None::<&str>),
1194 ("NAP_DIR", Some(nap_dir_str)),
1195 ],
1196 || {
1197 let backend = LoreBackend::from_env();
1198 assert_eq!(backend.remote_url, PORTALS_CLOUD_URL);
1199 assert_eq!(backend.workspace_id, "default");
1200 },
1201 );
1202 }
1203
1204 #[test]
1205 fn test_from_env_unknown_provider_type() {
1206 let temp_dir = tempfile::TempDir::new().unwrap();
1208 let provider_config = temp_dir.path().join("provider.toml");
1209 std::fs::write(
1210 &provider_config,
1211 r#"
1212provider_type = "unknown-provider"
1213"#,
1214 )
1215 .unwrap();
1216
1217 let nap_dir_str = temp_dir.path().to_str().unwrap();
1218 temp_env::with_vars(
1219 vec![
1220 ("NAP_LORE_URL_BASE", None::<&str>),
1221 ("NAP_WORKSPACE_ID", None::<&str>),
1222 ("NAP_DIR", Some(nap_dir_str)),
1223 ],
1224 || {
1225 let backend = LoreBackend::from_env();
1226 assert_eq!(backend.remote_url, "lore://localhost:41337");
1227 assert_eq!(backend.workspace_id, "default");
1228 },
1229 );
1230 }
1231
1232 #[test]
1233 fn test_repo_url_joining() {
1234 let backend = LoreBackend::new("lore://localhost:8700", "ws");
1235 assert_eq!(backend.repo_url("my-repo"), "lore://localhost:8700/my-repo");
1236
1237 let backend2 = LoreBackend::new("lore://host:8700/", "ws");
1239 assert_eq!(backend2.repo_url("foo"), "lore://host:8700/foo");
1240 }
1241
1242 #[test]
1243 fn test_list_branches_empty_json() {
1244 }
1249
1250 #[test]
1251 fn test_commit_parses_signature_from_stdout() {
1252 let sample = "Created revision a1b2c3d4 (#42)";
1256 let signature = sample
1257 .strip_prefix("Created revision ")
1258 .and_then(|s| s.split_whitespace().next())
1259 .unwrap_or(sample);
1260 assert_eq!(signature, "a1b2c3d4");
1261 }
1262
1263 #[test]
1266 fn test_commit_info_from_lore_revision() {
1267 let info = CommitInfo::from_lore_revision(
1268 "sig123",
1269 Some("sig122"),
1270 "alice",
1271 "feat: add manifest",
1272 "2026-06-30T12:00:00Z",
1273 );
1274 assert_eq!(info.id, "sig123");
1275 assert_eq!(info.parent.as_deref(), Some("sig122"));
1276 assert_eq!(info.author, "alice");
1277 assert_eq!(info.message, "feat: add manifest");
1278 assert_eq!(info.timestamp, "2026-06-30T12:00:00Z");
1279 }
1280
1281 #[test]
1282 fn test_commit_info_default_timestamp() {
1283 let info = CommitInfo::from_lore_revision("sig", None, "bob", "msg", "");
1285 assert!(
1286 info.timestamp.contains('T') || info.timestamp.contains('Z'),
1287 "expected RFC 3339 timestamp, got: {}",
1288 info.timestamp
1289 );
1290 }
1291}