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 = "lore://cloud.portals.sh:41337";
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 a lore workspace")
131 || stderr.contains("not an initialised lore workspace")
132 {
133 NapError::VcsError(format!(
134 "not a lore workspace at {:?}",
135 cwd.unwrap_or(Path::new("."))
136 ))
137 } else if stderr.contains("not found") {
138 NapError::VcsError(format!("path not found in lore workspace: {}", stderr))
139 } else {
140 NapError::VcsError(format!(
141 "lore CLI exited with code {}: {}",
142 exit_code, stderr
143 ))
144 }
145 }
146 64..=126 => {
147 NapError::VcsError(format!(
149 "lore CLI configuration error ({}): {}",
150 exit_code, stderr
151 ))
152 }
153 _ => NapError::VcsError(format!(
154 "lore CLI exited with code {}: {}",
155 exit_code, stderr
156 )),
157 };
158
159 Err(nap_err)
160 }
161}
162
163static TEMP_BLOB_COUNTER: AtomicU64 = AtomicU64::new(0);
164
165fn temp_lore_output_path(prefix: &str) -> PathBuf {
166 let unique = TEMP_BLOB_COUNTER.fetch_add(1, Ordering::SeqCst);
167 std::env::temp_dir().join(format!(
168 "nap-{prefix}-{}-{}.tmp",
169 std::process::id(),
170 unique
171 ))
172}
173
174fn parse_metadata_output(stdout: &str) -> Result<BTreeMap<String, String>, String> {
175 if let Ok(value) = serde_json::from_str::<serde_json::Value>(stdout) {
176 let mut metadata = BTreeMap::new();
177 if let serde_json::Value::Object(map) = value {
178 for (key, value) in map {
179 let rendered = match value {
180 serde_json::Value::String(s) => s,
181 serde_json::Value::Bool(b) => b.to_string(),
182 serde_json::Value::Number(n) => n.to_string(),
183 serde_json::Value::Null => continue,
184 other => serde_json::to_string(&other).map_err(|e| e.to_string())?,
185 };
186 metadata.insert(key, rendered);
187 }
188 }
189 return Ok(metadata);
190 }
191
192 let mut metadata = BTreeMap::new();
193 for line in stdout.lines() {
194 let trimmed = line.trim();
195 if trimmed.is_empty() {
196 continue;
197 }
198 if let Some((key, value)) = trimmed.split_once('=').or_else(|| trimmed.split_once(':')) {
199 let key = key.trim();
200 if !key.is_empty() {
201 metadata.insert(key.to_string(), value.trim().to_string());
202 }
203 }
204 }
205 Ok(metadata)
206}
207
208#[derive(Debug, Clone)]
221pub struct LoreBackend {
222 remote_url: String,
224 workspace_id: String,
226}
227
228impl LoreBackend {
229 pub fn new(remote_url: &str, workspace_id: &str) -> Self {
234 Self {
235 remote_url: remote_url.to_string(),
236 workspace_id: workspace_id.to_string(),
237 }
238 }
239
240 pub fn remote_url(&self) -> &str {
241 &self.remote_url
242 }
243
244 pub fn clone_repo(url: &str, dest: &Path) -> Result<(), NapError> {
250 LoreProcessRunner::run(
251 [
252 "clone",
253 url,
254 dest.to_str().unwrap_or("."),
255 "--non-interactive",
256 ],
257 None,
258 )?;
259 Ok(())
260 }
261
262 pub fn from_env() -> Self {
275 if let Ok(nap_dir) = std::env::var("NAP_DIR") {
277 let manager = crate::server::manager::ServerManager::new(Path::new(&nap_dir));
278 let _ = tokio::runtime::Handle::try_current().map(|handle| {
279 handle.block_on(async {
280 let _ = manager.ensure_running().await;
281 });
282 });
283 }
284
285 let url_from_env = std::env::var("NAP_LORE_URL_BASE").ok();
287 let workspace_from_env = std::env::var("NAP_WORKSPACE_ID").ok();
288
289 if url_from_env.is_some() || workspace_from_env.is_some() {
290 let base = url_from_env.unwrap_or_else(|| "lore://localhost:41337".to_string());
291 let workspace_id = workspace_from_env.unwrap_or_else(|| "default".to_string());
292 tracing::debug!(
293 url_base = %base,
294 workspace_id = %workspace_id,
295 "LoreBackend::from_env using environment variables (override)"
296 );
297 return Self {
298 remote_url: base,
299 workspace_id,
300 };
301 }
302
303 let nap_dir = if let Ok(nap_dir_str) = std::env::var("NAP_DIR") {
305 let path = PathBuf::from(&nap_dir_str);
307 if let Some(s) = path.to_str() {
308 if let Some(stripped) = s.strip_prefix('~') {
309 let home = std::env::var("HOME")
310 .or_else(|_| std::env::var("USERPROFILE"))
311 .unwrap_or_else(|_| ".".to_string());
312 PathBuf::from(home).join(stripped.trim_start_matches('/'))
313 } else {
314 path
315 }
316 } else {
317 path
318 }
319 } else {
320 let home = std::env::var("HOME")
322 .or_else(|_| std::env::var("USERPROFILE"))
323 .unwrap_or_else(|_| ".".to_string());
324 PathBuf::from(home).join(".nap")
325 };
326
327 let provider_config_path = nap_dir.join("provider.toml");
328 if provider_config_path.exists()
329 && let Ok(config_content) = std::fs::read_to_string(&provider_config_path)
330 && let Ok(config) = toml::from_str::<ProviderConfigToml>(&config_content)
331 {
332 match config.provider_type.as_str() {
333 "local" => {
334 tracing::debug!(
336 url_base = "lore://localhost:41337",
337 workspace_id = "default",
338 "LoreBackend::from_env using local provider configuration"
339 );
340 return Self {
341 remote_url: "lore://localhost:41337".to_string(),
342 workspace_id: "default".to_string(),
343 };
344 }
345 "remote" => {
346 if let (Some(url), Some(workspace)) = (config.remote_url, config.workspace_id) {
348 tracing::debug!(
349 url_base = %url,
350 workspace_id = %workspace,
351 "LoreBackend::from_env using remote provider configuration"
352 );
353 return Self {
354 remote_url: url,
355 workspace_id: workspace,
356 };
357 }
358 }
359 "portals-cloud" => {
360 let workspace_id = config.workspace_id.unwrap_or_else(|| "default".to_string());
362 tracing::debug!(
363 url_base = %PORTALS_CLOUD_URL,
364 workspace_id = %workspace_id,
365 "LoreBackend::from_env using portals-cloud provider configuration"
366 );
367 return Self {
368 remote_url: PORTALS_CLOUD_URL.to_string(),
369 workspace_id,
370 };
371 }
372 _ => {
373 tracing::debug!(
374 provider_type = %config.provider_type,
375 "Unknown provider type, falling back to defaults"
376 );
377 }
378 }
379 }
380
381 let base = "lore://localhost:41337".to_string();
383 let workspace_id = "default".to_string();
384 tracing::debug!(
385 url_base = %base,
386 workspace_id = %workspace_id,
387 "LoreBackend::from_env using defaults"
388 );
389 Self {
390 remote_url: base,
391 workspace_id,
392 }
393 }
394
395 pub fn from_provider(url_base: &str, workspace_id: &str) -> Self {
399 tracing::debug!(
400 url_base = %url_base,
401 workspace_id = %workspace_id,
402 "Creating LoreBackend from provider configuration"
403 );
404
405 Self {
406 remote_url: url_base.to_string(),
407 workspace_id: workspace_id.to_string(),
408 }
409 }
410
411 fn repo_url(&self, repo_id: &str) -> String {
413 format!("{}/{}", self.remote_url.trim_end_matches('/'), repo_id)
414 }
415}
416
417impl VcsBackend for LoreBackend {
418 fn remote_url_base(&self) -> Result<String, NapError> {
420 Ok(self.remote_url.clone())
421 }
422
423 fn init(&self, path: &Path) -> Result<(), NapError> {
425 let repo_id = path
434 .file_name()
435 .and_then(|n| n.to_str())
436 .unwrap_or("nap-repo");
437
438 let url = self.repo_url(repo_id);
439 let path_str = path.to_str().unwrap_or(".");
440
441 let server_path = path
443 .parent()
444 .unwrap_or(path)
445 .join(".lore-server")
446 .join(repo_id);
447
448 LoreProcessRunner::run(
450 [
451 "repository",
452 "create",
453 &url,
454 "--id",
455 &self.workspace_id,
456 "--repository",
457 server_path.to_str().unwrap_or("."),
458 "--non-interactive",
459 ],
460 None,
461 )
462 .map_err(|e| {
463 NapError::VcsError(format!("failed to create lore repository '{}': {}", url, e))
464 })?;
465
466 LoreProcessRunner::run(["clone", &url, path_str, "--non-interactive"], None).map_err(
468 |e| {
469 NapError::VcsError(format!(
470 "failed to clone lore repository to {:?}: {}",
471 path, e
472 ))
473 },
474 )?;
475
476 Ok(())
477 }
478
479 fn commit(&self, path: &Path, message: &str, author: &str) -> Result<String, NapError> {
481 LoreProcessRunner::run(["stage", "--scan", ".", "--non-interactive"], Some(path))?;
484
485 let stdout = LoreProcessRunner::run(
487 [
488 "revision",
489 "commit",
490 message,
491 "--identity",
492 author,
493 "--non-interactive",
494 ],
495 Some(path),
496 )?;
497
498 let signature = stdout
501 .lines()
502 .find_map(|line| {
503 line.strip_prefix("Signature :")
504 .or_else(|| line.strip_prefix("Signature:"))
505 })
506 .map(|s| s.trim().to_string())
507 .unwrap_or_else(|| {
508 stdout
510 .lines()
511 .next()
512 .unwrap_or(&stdout)
513 .trim()
514 .strip_prefix("Created revision ")
515 .and_then(|s| s.split_whitespace().next())
516 .map(|s| s.to_string())
517 .unwrap_or_else(|| stdout.trim().to_string())
518 });
519
520 Ok(signature)
521 }
522
523 fn read_file_at_ref(
525 &self,
526 repo_path: &Path,
527 file_path: &str,
528 reference: Option<&str>,
529 ) -> Result<String, NapError> {
530 let Some(reference) = reference else {
531 let full_path = repo_path.join(file_path);
532 return std::fs::read_to_string(&full_path).map_err(|e| {
533 NapError::VcsError(format!("failed to read {}: {}", full_path.display(), e))
534 });
535 };
536
537 let output_path = temp_lore_output_path("file-at-ref");
538 let output = output_path.to_string_lossy().into_owned();
539 LoreProcessRunner::run(
540 [
541 "file",
542 "write",
543 "--path",
544 file_path,
545 "--revision",
546 reference,
547 "--output",
548 &output,
549 "--non-interactive",
550 ],
551 Some(repo_path),
552 )?;
553
554 let content = std::fs::read_to_string(&output_path).map_err(|e| {
555 NapError::VcsError(format!(
556 "failed to read {} at revision {} from {}: {}",
557 file_path,
558 reference,
559 output_path.display(),
560 e
561 ))
562 })?;
563 let _ = std::fs::remove_file(&output_path);
564 Ok(content)
565 }
566
567 fn file_metadata_at_ref(
569 &self,
570 repo_path: &Path,
571 file_path: &str,
572 reference: &str,
573 ) -> Result<Option<BTreeMap<String, String>>, NapError> {
574 let stdout = LoreProcessRunner::run(
575 [
576 "file",
577 "metadata",
578 "get",
579 file_path,
580 "--revision",
581 reference,
582 "--non-interactive",
583 ],
584 Some(repo_path),
585 )?;
586
587 if stdout.trim().is_empty() || stdout.trim() == "null" {
588 return Ok(None);
589 }
590
591 parse_metadata_output(&stdout)
592 .map(Some)
593 .map_err(|e| NapError::VcsError(format!("failed to parse lore file metadata: {e}")))
594 }
595
596 fn read_provenance_blob(&self, repo_path: &Path, address: &str) -> Result<String, NapError> {
597 let output_path = temp_lore_output_path("provenance-blob");
598 let output = output_path.to_string_lossy().into_owned();
599
600 LoreProcessRunner::run(
601 [
602 "file",
603 "write",
604 "--address",
605 address,
606 "--output",
607 &output,
608 "--non-interactive",
609 ],
610 Some(repo_path),
611 )?;
612
613 let content = std::fs::read_to_string(&output_path).map_err(|e| {
614 NapError::VcsError(format!(
615 "failed to read hydrated provenance blob {} from {}: {}",
616 address,
617 output_path.display(),
618 e
619 ))
620 })?;
621 let _ = std::fs::remove_file(&output_path);
622 Ok(content)
623 }
624
625 fn log(
627 &self,
628 path: &Path,
629 _file: Option<&str>,
630 limit: usize,
631 ) -> Result<Vec<CommitInfo>, NapError> {
632 let limit_str = limit.to_string();
633 let args = vec!["history", &limit_str, "--non-interactive"];
634
635 let stdout = LoreProcessRunner::run(&args, Some(path))?;
636
637 if stdout.trim().is_empty() {
638 return Ok(Vec::new());
639 }
640
641 let mut commits = Vec::new();
650 let mut current_signature = String::new();
651 let mut current_author = String::new();
652 let mut current_message = String::new();
653 let mut current_timestamp = String::new();
654 let mut current_parent: Option<String> = None;
655 let mut in_message = false;
656
657 for line in stdout.lines() {
658 let trimmed = line.trim();
659 if trimmed.starts_with("Signature :") || trimmed.starts_with("Signature:") {
660 if !current_signature.is_empty() {
662 commits.push(CommitInfo {
663 id: std::mem::take(&mut current_signature),
664 parent: current_parent.take(),
665 author: std::mem::take(&mut current_author),
666 message: std::mem::take(&mut current_message),
667 timestamp: std::mem::take(&mut current_timestamp),
668 });
669 }
670 current_signature = trimmed
671 .strip_prefix("Signature :")
672 .or_else(|| trimmed.strip_prefix("Signature:"))
673 .unwrap_or("")
674 .trim()
675 .to_string();
676 in_message = false;
677 } else if trimmed.starts_with("Date :") || trimmed.starts_with("Date:") {
678 current_timestamp = trimmed
679 .split_once(':')
680 .map(|(_, v)| v.trim().to_string())
681 .unwrap_or_default();
682 in_message = true;
683 } else if trimmed.starts_with("Creator :") || trimmed.starts_with("Creator:") {
684 current_author = trimmed
685 .split_once(':')
686 .map(|(_, v)| v.trim().to_string())
687 .unwrap_or_default();
688 in_message = false;
689 } else if trimmed.starts_with("Revision :")
690 || trimmed.starts_with("Revision:")
691 || trimmed.starts_with("Branch :")
692 || trimmed.starts_with("Branch:")
693 || trimmed.starts_with("Committer :")
694 || trimmed.starts_with("Committer:")
695 {
696 in_message = false;
697 } else if in_message {
698 if trimmed.is_empty() || trimmed == "Commit succeeded" {
699 in_message = false;
700 } else {
701 if !current_message.is_empty() {
702 current_message.push('\n');
703 }
704 current_message.push_str(trimmed);
705 }
706 }
707 }
708 if !current_signature.is_empty() {
710 commits.push(CommitInfo {
711 id: current_signature,
712 parent: current_parent,
713 author: current_author,
714 message: current_message,
715 timestamp: current_timestamp,
716 });
717 }
718
719 Ok(commits)
720 }
721
722 fn create_branch(&self, path: &Path, name: &str) -> Result<(), NapError> {
724 LoreProcessRunner::run(["branch", "create", name, "--non-interactive"], Some(path))?;
725 Ok(())
726 }
727
728 fn switch_branch(&self, path: &Path, name: &str) -> Result<(), NapError> {
729 LoreProcessRunner::run(["branch", "switch", name, "--non-interactive"], Some(path))?;
730 Ok(())
731 }
732
733 fn current_branch(&self, path: &Path) -> Result<String, NapError> {
734 let stdout = LoreProcessRunner::run(["branch", "show", "--non-interactive"], Some(path))?;
735 Ok(stdout.trim().to_string())
736 }
737
738 fn list_branches(&self, path: &Path) -> Result<Vec<String>, NapError> {
739 let stdout = LoreProcessRunner::run(["branch", "list", "--non-interactive"], Some(path))?;
740 if stdout.is_empty() {
741 return Ok(Vec::new());
742 }
743 let mut branches = Vec::new();
750 let mut in_local = false;
751 for line in stdout.lines() {
752 let trimmed = line.trim();
753 if trimmed.starts_with("Local branches") {
754 in_local = true;
755 continue;
756 }
757 if trimmed.starts_with("Remote branches") {
758 in_local = false;
759 continue;
760 }
761 if in_local && !trimmed.is_empty() {
762 let name = trimmed.strip_prefix("* ").unwrap_or(trimmed);
764 branches.push(name.to_string());
765 }
766 }
767 Ok(branches)
768 }
769
770 fn head_hash(&self, path: &Path) -> Result<String, NapError> {
772 let stdout = LoreProcessRunner::run(["history", "1", "--non-interactive"], Some(path))?;
773
774 if stdout.trim().is_empty() {
775 return Err(NapError::VcsError(
776 "no commits in lore workspace".to_string(),
777 ));
778 }
779
780 stdout
782 .lines()
783 .find_map(|line| {
784 line.trim()
785 .strip_prefix("Signature :")
786 .or_else(|| line.trim().strip_prefix("Signature:"))
787 })
788 .map(|s| s.trim().to_string())
789 .ok_or_else(|| {
790 NapError::VcsError(format!(
791 "failed to parse signature from lore history: {stdout}"
792 ))
793 })
794 }
795
796 fn revert(&self, path: &Path, commit_hash: &str) -> Result<String, NapError> {
797 let stdout = LoreProcessRunner::run(
798 ["revision", "revert", commit_hash, "--non-interactive"],
799 Some(path),
800 )?;
801 let signature = stdout
803 .trim()
804 .strip_prefix("Created revert revision ")
805 .unwrap_or(stdout.trim());
806 Ok(signature.to_string())
807 }
808
809 fn resolve_branch_head(&self, path: &Path, branch: &str) -> Result<String, NapError> {
810 let stdout = LoreProcessRunner::run(
811 ["history", "1", "--branch", branch, "--non-interactive"],
812 Some(path),
813 )?;
814
815 if stdout.trim().is_empty() {
816 return Err(NapError::VcsError(format!(
817 "no commits found on branch '{branch}'"
818 )));
819 }
820
821 stdout
823 .lines()
824 .find_map(|line| {
825 line.trim()
826 .strip_prefix("Signature :")
827 .or_else(|| line.trim().strip_prefix("Signature:"))
828 })
829 .map(|s| s.trim().to_string())
830 .ok_or_else(|| {
831 NapError::VcsError(format!(
832 "failed to parse signature from lore history on branch '{branch}': {stdout}"
833 ))
834 })
835 }
836
837 fn add_remote(&self, path: &Path, name: &str, url: &str) -> Result<(), NapError> {
839 LoreProcessRunner::run(
840 [
841 "repository",
842 "add",
843 url,
844 "--alias",
845 name,
846 "--non-interactive",
847 ],
848 Some(path),
849 )?;
850 Ok(())
851 }
852
853 fn remove_remote(&self, path: &Path, name: &str) -> Result<(), NapError> {
854 LoreProcessRunner::run(
855 ["repository", "remove", "--alias", name, "--non-interactive"],
856 Some(path),
857 )?;
858 Ok(())
859 }
860
861 fn list_remotes(&self, path: &Path) -> Result<Vec<(String, String)>, NapError> {
862 let stdout = LoreProcessRunner::run(
863 [
864 "repository",
865 "list",
866 "--format",
867 "json",
868 "--non-interactive",
869 ],
870 Some(path),
871 )?;
872
873 if stdout.is_empty() || stdout == "[]" || stdout == "null" {
874 return Ok(Vec::new());
875 }
876
877 #[derive(serde::Deserialize)]
879 struct RemoteEntry {
880 #[allow(dead_code)]
881 name: String,
882 #[allow(dead_code)]
883 url: String,
884 }
885 let entries: Vec<RemoteEntry> = serde_json::from_str(&stdout).map_err(|e| {
886 NapError::VcsError(format!(
887 "failed to parse lore repository list JSON: {}. Raw: {}",
888 e, stdout
889 ))
890 })?;
891
892 let pairs: Vec<(String, String)> = entries.into_iter().map(|e| (e.name, e.url)).collect();
893 Ok(pairs)
894 }
895
896 fn push(
898 &self,
899 path: &Path,
900 _remote: Option<&str>,
901 branch: Option<&str>,
902 ) -> Result<(), NapError> {
903 let branch_name = match branch {
906 Some(b) => b.to_string(),
907 None => self
908 .current_branch(path)
909 .unwrap_or_else(|_| "main".to_string()),
910 };
911
912 let args = vec![
914 "branch",
915 "push",
916 &branch_name,
917 "--fast-forward-merge",
918 "--non-interactive",
919 ];
920 LoreProcessRunner::run(&args, Some(path))?;
921
922 Ok(())
923 }
924
925 fn pull(
926 &self,
927 path: &Path,
928 _remote: Option<&str>,
929 _branch: Option<&str>,
930 ) -> Result<(), NapError> {
931 let args = vec!["sync", "--non-interactive", "--reset"];
933 LoreProcessRunner::run(&args, Some(path))?;
934
935 Ok(())
936 }
937}
938
939#[cfg(all(test, feature = "lore-integration"))]
944mod tests {
945 use super::*;
946
947 #[test]
950 fn test_binary_default() {
951 assert_eq!(LoreProcessRunner::binary(), "lore");
952 }
953
954 #[test]
955 fn test_binary_from_env() {
956 temp_env::with_var("NAPLORE_CLI", Some("/custom/lore"), || {
957 assert_eq!(LoreProcessRunner::binary(), "/custom/lore");
958 });
959 }
960
961 #[test]
962 fn test_run_captures_stdout() {
963 temp_env::with_var("NAPLORE_CLI", Some("lore-nonexistent-binary-12345"), || {
967 let result = LoreProcessRunner::run(["--version"], None);
968 assert!(result.is_err());
969 let err = result.unwrap_err().to_string();
970 assert!(
971 err.contains("lore-nonexistent-binary-12345"),
972 "error: {}",
973 err
974 );
975 });
976 }
977
978 #[test]
981 fn test_new_and_from_env() {
982 let backend = LoreBackend::new("lore://myhost:8700", "test-workspace");
983 assert_eq!(backend.remote_url, "lore://myhost:8700");
984 assert_eq!(backend.workspace_id, "test-workspace");
985
986 temp_env::with_vars(
987 vec![
988 ("NAP_LORE_URL_BASE", Some("lore://custom:9999")),
989 ("NAP_WORKSPACE_ID", Some("custom-ws")),
990 ],
991 || {
992 let from_env = LoreBackend::from_env();
993 assert_eq!(from_env.remote_url, "lore://custom:9999");
994 assert_eq!(from_env.workspace_id, "custom-ws");
995 },
996 );
997 }
998
999 #[test]
1000 fn test_from_env_default_without_env_vars() {
1001 let temp_dir = tempfile::TempDir::new().unwrap();
1003 let nap_dir_str = temp_dir.path().to_str().unwrap();
1004
1005 temp_env::with_vars(
1006 vec![
1007 ("NAP_LORE_URL_BASE", None::<&str>),
1008 ("NAP_WORKSPACE_ID", None::<&str>),
1009 ("NAP_DIR", Some(nap_dir_str)),
1010 ],
1011 || {
1012 let backend = LoreBackend::from_env();
1013 assert_eq!(backend.remote_url, "lore://localhost:41337");
1014 assert_eq!(backend.workspace_id, "default");
1015 },
1016 );
1017 }
1018
1019 #[test]
1020 fn test_from_env_env_var_override() {
1021 let temp_dir = tempfile::TempDir::new().unwrap();
1023 let nap_dir_str = temp_dir.path().to_str().unwrap();
1024
1025 temp_env::with_vars(
1026 vec![
1027 ("NAP_LORE_URL_BASE", Some("lore://override:1234")),
1028 ("NAP_WORKSPACE_ID", Some("override-ws")),
1029 ("NAP_DIR", Some(nap_dir_str)),
1030 ],
1031 || {
1032 let backend = LoreBackend::from_env();
1033 assert_eq!(backend.remote_url, "lore://override:1234");
1034 assert_eq!(backend.workspace_id, "override-ws");
1035 },
1036 );
1037 }
1038
1039 #[test]
1040 fn test_from_env_partial_env_override() {
1041 let temp_dir = tempfile::TempDir::new().unwrap();
1043 let nap_dir_str = temp_dir.path().to_str().unwrap();
1044
1045 temp_env::with_vars(
1046 vec![
1047 ("NAP_LORE_URL_BASE", Some("lore://partial:5678")),
1048 ("NAP_WORKSPACE_ID", None::<&str>),
1049 ("NAP_DIR", Some(nap_dir_str)),
1050 ],
1051 || {
1052 let backend = LoreBackend::from_env();
1053 assert_eq!(backend.remote_url, "lore://partial:5678");
1054 assert_eq!(backend.workspace_id, "default");
1055 },
1056 );
1057 }
1058
1059 #[test]
1060 fn test_from_env_provider_config() {
1061 let temp_dir = tempfile::TempDir::new().unwrap();
1063 let provider_config = temp_dir.path().join("provider.toml");
1064 std::fs::write(
1065 &provider_config,
1066 r#"
1067provider_type = "remote"
1068remote_url = "lore://provider:9999"
1069workspace_id = "provider-ws"
1070"#,
1071 )
1072 .unwrap();
1073
1074 let nap_dir_str = temp_dir.path().to_str().unwrap();
1075 temp_env::with_vars(
1076 vec![
1077 ("NAP_LORE_URL_BASE", None::<&str>),
1078 ("NAP_WORKSPACE_ID", None::<&str>),
1079 ("NAP_DIR", Some(nap_dir_str)),
1080 ],
1081 || {
1082 let backend = LoreBackend::from_env();
1083 assert_eq!(backend.remote_url, "lore://provider:9999");
1084 assert_eq!(backend.workspace_id, "provider-ws");
1085 },
1086 );
1087 }
1088
1089 #[test]
1090 fn test_from_env_nap_dir_with_tilde() {
1091 let _home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
1093 let temp_dir = tempfile::TempDir::new().unwrap();
1094 let nap_dir_str = temp_dir.path().to_str().unwrap();
1095
1096 temp_env::with_vars(
1097 vec![
1098 ("NAP_LORE_URL_BASE", None::<&str>),
1099 ("NAP_WORKSPACE_ID", None::<&str>),
1100 ("NAP_DIR", Some(nap_dir_str)),
1101 ],
1102 || {
1103 let backend = LoreBackend::from_env();
1104 assert_eq!(backend.remote_url, "lore://localhost:41337");
1106 assert_eq!(backend.workspace_id, "default");
1107 },
1108 );
1109 }
1110
1111 #[test]
1112 fn test_from_env_local_provider_config() {
1113 let temp_dir = tempfile::TempDir::new().unwrap();
1115 let provider_config = temp_dir.path().join("provider.toml");
1116 std::fs::write(
1117 &provider_config,
1118 r#"
1119provider_type = "local"
1120"#,
1121 )
1122 .unwrap();
1123
1124 let nap_dir_str = temp_dir.path().to_str().unwrap();
1125 temp_env::with_vars(
1126 vec![
1127 ("NAP_LORE_URL_BASE", None::<&str>),
1128 ("NAP_WORKSPACE_ID", None::<&str>),
1129 ("NAP_DIR", Some(nap_dir_str)),
1130 ],
1131 || {
1132 let backend = LoreBackend::from_env();
1133 assert_eq!(backend.remote_url, "lore://localhost:41337");
1134 assert_eq!(backend.workspace_id, "default");
1135 },
1136 );
1137 }
1138
1139 #[test]
1140 fn test_from_env_portals_cloud_provider_config() {
1141 let temp_dir = tempfile::TempDir::new().unwrap();
1143 let provider_config = temp_dir.path().join("provider.toml");
1144 std::fs::write(
1145 &provider_config,
1146 r#"
1147provider_type = "portals-cloud"
1148workspace_id = "cloud-ws"
1149"#,
1150 )
1151 .unwrap();
1152
1153 let nap_dir_str = temp_dir.path().to_str().unwrap();
1154 temp_env::with_vars(
1155 vec![
1156 ("NAP_LORE_URL_BASE", None::<&str>),
1157 ("NAP_WORKSPACE_ID", None::<&str>),
1158 ("NAP_DIR", Some(nap_dir_str)),
1159 ],
1160 || {
1161 let backend = LoreBackend::from_env();
1162 assert_eq!(backend.remote_url, PORTALS_CLOUD_URL);
1163 assert_eq!(backend.workspace_id, "cloud-ws");
1164 },
1165 );
1166 }
1167
1168 #[test]
1169 fn test_from_env_portals_cloud_default_workspace() {
1170 let temp_dir = tempfile::TempDir::new().unwrap();
1172 let provider_config = temp_dir.path().join("provider.toml");
1173 std::fs::write(
1174 &provider_config,
1175 r#"
1176provider_type = "portals-cloud"
1177"#,
1178 )
1179 .unwrap();
1180
1181 let nap_dir_str = temp_dir.path().to_str().unwrap();
1182 temp_env::with_vars(
1183 vec![
1184 ("NAP_LORE_URL_BASE", None::<&str>),
1185 ("NAP_WORKSPACE_ID", None::<&str>),
1186 ("NAP_DIR", Some(nap_dir_str)),
1187 ],
1188 || {
1189 let backend = LoreBackend::from_env();
1190 assert_eq!(backend.remote_url, PORTALS_CLOUD_URL);
1191 assert_eq!(backend.workspace_id, "default");
1192 },
1193 );
1194 }
1195
1196 #[test]
1197 fn test_from_env_unknown_provider_type() {
1198 let temp_dir = tempfile::TempDir::new().unwrap();
1200 let provider_config = temp_dir.path().join("provider.toml");
1201 std::fs::write(
1202 &provider_config,
1203 r#"
1204provider_type = "unknown-provider"
1205"#,
1206 )
1207 .unwrap();
1208
1209 let nap_dir_str = temp_dir.path().to_str().unwrap();
1210 temp_env::with_vars(
1211 vec![
1212 ("NAP_LORE_URL_BASE", None::<&str>),
1213 ("NAP_WORKSPACE_ID", None::<&str>),
1214 ("NAP_DIR", Some(nap_dir_str)),
1215 ],
1216 || {
1217 let backend = LoreBackend::from_env();
1218 assert_eq!(backend.remote_url, "lore://localhost:41337");
1219 assert_eq!(backend.workspace_id, "default");
1220 },
1221 );
1222 }
1223
1224 #[test]
1225 fn test_repo_url_joining() {
1226 let backend = LoreBackend::new("lore://localhost:8700", "ws");
1227 assert_eq!(backend.repo_url("my-repo"), "lore://localhost:8700/my-repo");
1228
1229 let backend2 = LoreBackend::new("lore://host:8700/", "ws");
1231 assert_eq!(backend2.repo_url("foo"), "lore://host:8700/foo");
1232 }
1233
1234 #[test]
1235 fn test_list_branches_empty_json() {
1236 }
1241
1242 #[test]
1243 fn test_commit_parses_signature_from_stdout() {
1244 let sample = "Created revision a1b2c3d4 (#42)";
1248 let signature = sample
1249 .strip_prefix("Created revision ")
1250 .and_then(|s| s.split_whitespace().next())
1251 .unwrap_or(sample);
1252 assert_eq!(signature, "a1b2c3d4");
1253 }
1254
1255 #[test]
1258 fn test_commit_info_from_lore_revision() {
1259 let info = CommitInfo::from_lore_revision(
1260 "sig123",
1261 Some("sig122"),
1262 "alice",
1263 "feat: add manifest",
1264 "2026-06-30T12:00:00Z",
1265 );
1266 assert_eq!(info.id, "sig123");
1267 assert_eq!(info.parent.as_deref(), Some("sig122"));
1268 assert_eq!(info.author, "alice");
1269 assert_eq!(info.message, "feat: add manifest");
1270 assert_eq!(info.timestamp, "2026-06-30T12:00:00Z");
1271 }
1272
1273 #[test]
1274 fn test_commit_info_default_timestamp() {
1275 let info = CommitInfo::from_lore_revision("sig", None, "bob", "msg", "");
1277 assert!(
1278 info.timestamp.contains('T') || info.timestamp.contains('Z'),
1279 "expected RFC 3339 timestamp, got: {}",
1280 info.timestamp
1281 );
1282 }
1283}