1use std::path::{Path, PathBuf};
36use std::process::Command;
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
163#[derive(Debug, Clone)]
176pub struct LoreBackend {
177 remote_url: String,
179 workspace_id: String,
181}
182
183impl LoreBackend {
184 pub fn new(remote_url: &str, workspace_id: &str) -> Self {
189 Self {
190 remote_url: remote_url.to_string(),
191 workspace_id: workspace_id.to_string(),
192 }
193 }
194
195 pub fn clone_repo(url: &str, dest: &Path) -> Result<(), NapError> {
201 LoreProcessRunner::run(
202 [
203 "clone",
204 url,
205 dest.to_str().unwrap_or("."),
206 "--non-interactive",
207 ],
208 None,
209 )?;
210 Ok(())
211 }
212
213 pub fn from_env() -> Self {
226 if let Ok(nap_dir) = std::env::var("NAP_DIR") {
228 let manager = crate::server::manager::ServerManager::new(Path::new(&nap_dir));
229 let _ = tokio::runtime::Handle::try_current().map(|handle| {
230 handle.block_on(async {
231 let _ = manager.ensure_running().await;
232 });
233 });
234 }
235
236 let url_from_env = std::env::var("NAP_LORE_URL_BASE").ok();
238 let workspace_from_env = std::env::var("NAP_WORKSPACE_ID").ok();
239
240 if url_from_env.is_some() || workspace_from_env.is_some() {
241 let base = url_from_env.unwrap_or_else(|| "lore://localhost:41337".to_string());
242 let workspace_id = workspace_from_env.unwrap_or_else(|| "default".to_string());
243 tracing::debug!(
244 url_base = %base,
245 workspace_id = %workspace_id,
246 "LoreBackend::from_env using environment variables (override)"
247 );
248 return Self {
249 remote_url: base,
250 workspace_id,
251 };
252 }
253
254 let nap_dir = if let Ok(nap_dir_str) = std::env::var("NAP_DIR") {
256 let path = PathBuf::from(&nap_dir_str);
258 if let Some(s) = path.to_str() {
259 if let Some(stripped) = s.strip_prefix('~') {
260 let home = std::env::var("HOME")
261 .or_else(|_| std::env::var("USERPROFILE"))
262 .unwrap_or_else(|_| ".".to_string());
263 PathBuf::from(home).join(stripped.trim_start_matches('/'))
264 } else {
265 path
266 }
267 } else {
268 path
269 }
270 } else {
271 let home = std::env::var("HOME")
273 .or_else(|_| std::env::var("USERPROFILE"))
274 .unwrap_or_else(|_| ".".to_string());
275 PathBuf::from(home).join(".nap")
276 };
277
278 let provider_config_path = nap_dir.join("provider.toml");
279 if provider_config_path.exists()
280 && let Ok(config_content) = std::fs::read_to_string(&provider_config_path)
281 && let Ok(config) = toml::from_str::<ProviderConfigToml>(&config_content)
282 {
283 match config.provider_type.as_str() {
284 "local" => {
285 tracing::debug!(
287 url_base = "lore://localhost:41337",
288 workspace_id = "default",
289 "LoreBackend::from_env using local provider configuration"
290 );
291 return Self {
292 remote_url: "lore://localhost:41337".to_string(),
293 workspace_id: "default".to_string(),
294 };
295 }
296 "remote" => {
297 if let (Some(url), Some(workspace)) = (config.remote_url, config.workspace_id) {
299 tracing::debug!(
300 url_base = %url,
301 workspace_id = %workspace,
302 "LoreBackend::from_env using remote provider configuration"
303 );
304 return Self {
305 remote_url: url,
306 workspace_id: workspace,
307 };
308 }
309 }
310 "portals-cloud" => {
311 let workspace_id = config.workspace_id.unwrap_or_else(|| "default".to_string());
313 tracing::debug!(
314 url_base = %PORTALS_CLOUD_URL,
315 workspace_id = %workspace_id,
316 "LoreBackend::from_env using portals-cloud provider configuration"
317 );
318 return Self {
319 remote_url: PORTALS_CLOUD_URL.to_string(),
320 workspace_id,
321 };
322 }
323 _ => {
324 tracing::debug!(
325 provider_type = %config.provider_type,
326 "Unknown provider type, falling back to defaults"
327 );
328 }
329 }
330 }
331
332 let base = "lore://localhost:41337".to_string();
334 let workspace_id = "default".to_string();
335 tracing::debug!(
336 url_base = %base,
337 workspace_id = %workspace_id,
338 "LoreBackend::from_env using defaults"
339 );
340 Self {
341 remote_url: base,
342 workspace_id,
343 }
344 }
345
346 pub fn from_provider(url_base: &str, workspace_id: &str) -> Self {
350 tracing::debug!(
351 url_base = %url_base,
352 workspace_id = %workspace_id,
353 "Creating LoreBackend from provider configuration"
354 );
355
356 Self {
357 remote_url: url_base.to_string(),
358 workspace_id: workspace_id.to_string(),
359 }
360 }
361
362 fn repo_url(&self, repo_id: &str) -> String {
364 format!("{}/{}", self.remote_url.trim_end_matches('/'), repo_id)
365 }
366}
367
368impl VcsBackend for LoreBackend {
369 fn remote_url_base(&self) -> Result<String, NapError> {
371 Ok(self.remote_url.clone())
372 }
373
374 fn init(&self, path: &Path) -> Result<(), NapError> {
376 let repo_id = path
385 .file_name()
386 .and_then(|n| n.to_str())
387 .unwrap_or("nap-repo");
388
389 let url = self.repo_url(repo_id);
390 let path_str = path.to_str().unwrap_or(".");
391
392 let server_path = path
394 .parent()
395 .unwrap_or(path)
396 .join(".lore-server")
397 .join(repo_id);
398
399 LoreProcessRunner::run(
401 [
402 "repository",
403 "create",
404 &url,
405 "--id",
406 &self.workspace_id,
407 "--repository",
408 server_path.to_str().unwrap_or("."),
409 "--non-interactive",
410 ],
411 None,
412 )
413 .map_err(|e| {
414 NapError::VcsError(format!("failed to create lore repository '{}': {}", url, e))
415 })?;
416
417 LoreProcessRunner::run(["clone", &url, path_str, "--non-interactive"], None).map_err(
419 |e| {
420 NapError::VcsError(format!(
421 "failed to clone lore repository to {:?}: {}",
422 path, e
423 ))
424 },
425 )?;
426
427 Ok(())
428 }
429
430 fn commit(&self, path: &Path, message: &str, author: &str) -> Result<String, NapError> {
432 LoreProcessRunner::run(["stage", "--scan", ".", "--non-interactive"], Some(path))?;
435
436 let stdout = LoreProcessRunner::run(
438 [
439 "revision",
440 "commit",
441 message,
442 "--identity",
443 author,
444 "--non-interactive",
445 ],
446 Some(path),
447 )?;
448
449 let signature = stdout
452 .lines()
453 .find_map(|line| {
454 line.strip_prefix("Signature :")
455 .or_else(|| line.strip_prefix("Signature:"))
456 })
457 .map(|s| s.trim().to_string())
458 .unwrap_or_else(|| {
459 stdout
461 .lines()
462 .next()
463 .unwrap_or(&stdout)
464 .trim()
465 .strip_prefix("Created revision ")
466 .and_then(|s| s.split_whitespace().next())
467 .map(|s| s.to_string())
468 .unwrap_or_else(|| stdout.trim().to_string())
469 });
470
471 Ok(signature)
472 }
473
474 fn read_file_at_ref(
476 &self,
477 repo_path: &Path,
478 file_path: &str,
479 _reference: Option<&str>,
480 ) -> Result<String, NapError> {
481 let full_path = repo_path.join(file_path);
484 std::fs::read_to_string(&full_path).map_err(|e| {
485 NapError::VcsError(format!("failed to read {}: {}", full_path.display(), e))
486 })
487 }
488
489 fn log(
491 &self,
492 path: &Path,
493 _file: Option<&str>,
494 limit: usize,
495 ) -> Result<Vec<CommitInfo>, NapError> {
496 let limit_str = limit.to_string();
497 let args = vec!["history", &limit_str, "--non-interactive"];
498
499 let stdout = LoreProcessRunner::run(&args, Some(path))?;
500
501 if stdout.trim().is_empty() {
502 return Ok(Vec::new());
503 }
504
505 let mut commits = Vec::new();
514 let mut current_signature = String::new();
515 let mut current_author = String::new();
516 let mut current_message = String::new();
517 let mut current_timestamp = String::new();
518 let mut current_parent: Option<String> = None;
519 let mut in_message = false;
520
521 for line in stdout.lines() {
522 let trimmed = line.trim();
523 if trimmed.starts_with("Signature :") || trimmed.starts_with("Signature:") {
524 if !current_signature.is_empty() {
526 commits.push(CommitInfo {
527 id: std::mem::take(&mut current_signature),
528 parent: current_parent.take(),
529 author: std::mem::take(&mut current_author),
530 message: std::mem::take(&mut current_message),
531 timestamp: std::mem::take(&mut current_timestamp),
532 });
533 }
534 current_signature = trimmed
535 .strip_prefix("Signature :")
536 .or_else(|| trimmed.strip_prefix("Signature:"))
537 .unwrap_or("")
538 .trim()
539 .to_string();
540 in_message = false;
541 } else if trimmed.starts_with("Date :") || trimmed.starts_with("Date:") {
542 current_timestamp = trimmed
543 .split_once(':')
544 .map(|(_, v)| v.trim().to_string())
545 .unwrap_or_default();
546 in_message = true;
547 } else if trimmed.starts_with("Creator :") || trimmed.starts_with("Creator:") {
548 current_author = trimmed
549 .split_once(':')
550 .map(|(_, v)| v.trim().to_string())
551 .unwrap_or_default();
552 in_message = false;
553 } else if trimmed.starts_with("Revision :")
554 || trimmed.starts_with("Revision:")
555 || trimmed.starts_with("Branch :")
556 || trimmed.starts_with("Branch:")
557 || trimmed.starts_with("Committer :")
558 || trimmed.starts_with("Committer:")
559 {
560 in_message = false;
561 } else if in_message {
562 if trimmed.is_empty() || trimmed == "Commit succeeded" {
563 in_message = false;
564 } else {
565 if !current_message.is_empty() {
566 current_message.push('\n');
567 }
568 current_message.push_str(trimmed);
569 }
570 }
571 }
572 if !current_signature.is_empty() {
574 commits.push(CommitInfo {
575 id: current_signature,
576 parent: current_parent,
577 author: current_author,
578 message: current_message,
579 timestamp: current_timestamp,
580 });
581 }
582
583 Ok(commits)
584 }
585
586 fn create_branch(&self, path: &Path, name: &str) -> Result<(), NapError> {
588 LoreProcessRunner::run(["branch", "create", name, "--non-interactive"], Some(path))?;
589 Ok(())
590 }
591
592 fn switch_branch(&self, path: &Path, name: &str) -> Result<(), NapError> {
593 LoreProcessRunner::run(["branch", "switch", name, "--non-interactive"], Some(path))?;
594 Ok(())
595 }
596
597 fn current_branch(&self, path: &Path) -> Result<String, NapError> {
598 let stdout = LoreProcessRunner::run(["branch", "show", "--non-interactive"], Some(path))?;
599 Ok(stdout.trim().to_string())
600 }
601
602 fn list_branches(&self, path: &Path) -> Result<Vec<String>, NapError> {
603 let stdout = LoreProcessRunner::run(["branch", "list", "--non-interactive"], Some(path))?;
604 if stdout.is_empty() {
605 return Ok(Vec::new());
606 }
607 let mut branches = Vec::new();
614 let mut in_local = false;
615 for line in stdout.lines() {
616 let trimmed = line.trim();
617 if trimmed.starts_with("Local branches") {
618 in_local = true;
619 continue;
620 }
621 if trimmed.starts_with("Remote branches") {
622 in_local = false;
623 continue;
624 }
625 if in_local && !trimmed.is_empty() {
626 let name = trimmed.strip_prefix("* ").unwrap_or(trimmed);
628 branches.push(name.to_string());
629 }
630 }
631 Ok(branches)
632 }
633
634 fn create_tag(&self, path: &Path, name: &str) -> Result<(), NapError> {
636 let current = LoreProcessRunner::run(
640 [
641 "file",
642 "metadata",
643 "get",
644 "repository.yaml",
645 "nap.labels",
646 "--non-interactive",
647 ],
648 Some(path),
649 )
650 .unwrap_or_else(|_| String::new());
651
652 let json_str = current
654 .strip_prefix("nap.labels: ")
655 .map(|s| s.trim().to_string())
656 .unwrap_or_default();
657
658 let mut labels: Vec<String> =
659 if json_str.is_empty() || json_str == "[]" || json_str == "null" {
660 Vec::new()
661 } else {
662 serde_json::from_str(&json_str).unwrap_or_default()
663 };
664
665 if !labels.contains(&name.to_string()) {
666 labels.push(name.to_string());
667 }
668
669 let labels_json = serde_json::to_string(&labels)
670 .map_err(|e| NapError::VcsError(format!("failed to serialise label list: {}", e)))?;
671
672 LoreProcessRunner::run(
673 [
674 "file",
675 "metadata",
676 "set",
677 "repository.yaml",
678 "nap.labels",
679 &labels_json,
680 "--non-interactive",
681 ],
682 Some(path),
683 )?;
684
685 Ok(())
686 }
687
688 fn list_tags(&self, path: &Path) -> Result<Vec<String>, NapError> {
689 let stdout = LoreProcessRunner::run(
690 [
691 "file",
692 "metadata",
693 "get",
694 "repository.yaml",
695 "nap.labels",
696 "--non-interactive",
697 ],
698 Some(path),
699 )?;
700
701 if stdout.is_empty() {
702 return Ok(Vec::new());
703 }
704
705 let json_str = stdout
707 .strip_prefix("nap.labels: ")
708 .map(|s| s.trim().to_string())
709 .unwrap_or_else(|| stdout.trim().to_string());
710
711 if json_str.is_empty() || json_str == "[]" || json_str == "null" {
712 return Ok(Vec::new());
713 }
714
715 let labels: Vec<String> = serde_json::from_str(&json_str).map_err(|e| {
716 NapError::VcsError(format!(
717 "failed to parse lore labels JSON: {}. Raw: {}",
718 e, stdout
719 ))
720 })?;
721 Ok(labels)
722 }
723
724 fn head_hash(&self, path: &Path) -> Result<String, NapError> {
726 let stdout = LoreProcessRunner::run(["history", "1", "--non-interactive"], Some(path))?;
727
728 if stdout.trim().is_empty() {
729 return Err(NapError::VcsError(
730 "no commits in lore workspace".to_string(),
731 ));
732 }
733
734 stdout
736 .lines()
737 .find_map(|line| {
738 line.trim()
739 .strip_prefix("Signature :")
740 .or_else(|| line.trim().strip_prefix("Signature:"))
741 })
742 .map(|s| s.trim().to_string())
743 .ok_or_else(|| {
744 NapError::VcsError(format!(
745 "failed to parse signature from lore history: {stdout}"
746 ))
747 })
748 }
749
750 fn revert(&self, path: &Path, commit_hash: &str) -> Result<String, NapError> {
751 let stdout = LoreProcessRunner::run(
752 ["revision", "revert", commit_hash, "--non-interactive"],
753 Some(path),
754 )?;
755 let signature = stdout
757 .trim()
758 .strip_prefix("Created revert revision ")
759 .unwrap_or(stdout.trim());
760 Ok(signature.to_string())
761 }
762
763 fn resolve_branch_head(&self, path: &Path, branch: &str) -> Result<String, NapError> {
764 let stdout = LoreProcessRunner::run(
765 ["history", "1", "--branch", branch, "--non-interactive"],
766 Some(path),
767 )?;
768
769 if stdout.trim().is_empty() {
770 return Err(NapError::VcsError(format!(
771 "no commits found on branch '{branch}'"
772 )));
773 }
774
775 stdout
777 .lines()
778 .find_map(|line| {
779 line.trim()
780 .strip_prefix("Signature :")
781 .or_else(|| line.trim().strip_prefix("Signature:"))
782 })
783 .map(|s| s.trim().to_string())
784 .ok_or_else(|| {
785 NapError::VcsError(format!(
786 "failed to parse signature from lore history on branch '{branch}': {stdout}"
787 ))
788 })
789 }
790
791 fn add_remote(&self, path: &Path, name: &str, url: &str) -> Result<(), NapError> {
793 LoreProcessRunner::run(
794 [
795 "repository",
796 "add",
797 url,
798 "--alias",
799 name,
800 "--non-interactive",
801 ],
802 Some(path),
803 )?;
804 Ok(())
805 }
806
807 fn remove_remote(&self, path: &Path, name: &str) -> Result<(), NapError> {
808 LoreProcessRunner::run(
809 ["repository", "remove", "--alias", name, "--non-interactive"],
810 Some(path),
811 )?;
812 Ok(())
813 }
814
815 fn list_remotes(&self, path: &Path) -> Result<Vec<(String, String)>, NapError> {
816 let stdout = LoreProcessRunner::run(
817 [
818 "repository",
819 "list",
820 "--format",
821 "json",
822 "--non-interactive",
823 ],
824 Some(path),
825 )?;
826
827 if stdout.is_empty() || stdout == "[]" || stdout == "null" {
828 return Ok(Vec::new());
829 }
830
831 #[derive(serde::Deserialize)]
833 struct RemoteEntry {
834 #[allow(dead_code)]
835 name: String,
836 #[allow(dead_code)]
837 url: String,
838 }
839 let entries: Vec<RemoteEntry> = serde_json::from_str(&stdout).map_err(|e| {
840 NapError::VcsError(format!(
841 "failed to parse lore repository list JSON: {}. Raw: {}",
842 e, stdout
843 ))
844 })?;
845
846 let pairs: Vec<(String, String)> = entries.into_iter().map(|e| (e.name, e.url)).collect();
847 Ok(pairs)
848 }
849
850 fn push(
852 &self,
853 path: &Path,
854 _remote: Option<&str>,
855 branch: Option<&str>,
856 ) -> Result<(), NapError> {
857 let branch_name = match branch {
860 Some(b) => b.to_string(),
861 None => self
862 .current_branch(path)
863 .unwrap_or_else(|_| "main".to_string()),
864 };
865
866 let args = vec![
868 "branch",
869 "push",
870 &branch_name,
871 "--fast-forward-merge",
872 "--non-interactive",
873 ];
874 LoreProcessRunner::run(&args, Some(path))?;
875
876 Ok(())
877 }
878
879 fn pull(
880 &self,
881 path: &Path,
882 _remote: Option<&str>,
883 _branch: Option<&str>,
884 ) -> Result<(), NapError> {
885 let args = vec!["sync", "--non-interactive", "--reset"];
887 LoreProcessRunner::run(&args, Some(path))?;
888
889 Ok(())
890 }
891}
892
893#[cfg(all(test, feature = "lore-integration"))]
898mod tests {
899 use super::*;
900
901 #[test]
904 fn test_binary_default() {
905 assert_eq!(LoreProcessRunner::binary(), "lore");
906 }
907
908 #[test]
909 fn test_binary_from_env() {
910 temp_env::with_var("NAPLORE_CLI", Some("/custom/lore"), || {
911 assert_eq!(LoreProcessRunner::binary(), "/custom/lore");
912 });
913 }
914
915 #[test]
916 fn test_run_captures_stdout() {
917 temp_env::with_var("NAPLORE_CLI", Some("lore-nonexistent-binary-12345"), || {
921 let result = LoreProcessRunner::run(["--version"], None);
922 assert!(result.is_err());
923 let err = result.unwrap_err().to_string();
924 assert!(
925 err.contains("lore-nonexistent-binary-12345"),
926 "error: {}",
927 err
928 );
929 });
930 }
931
932 #[test]
935 fn test_new_and_from_env() {
936 let backend = LoreBackend::new("lore://myhost:8700", "test-workspace");
937 assert_eq!(backend.remote_url, "lore://myhost:8700");
938 assert_eq!(backend.workspace_id, "test-workspace");
939
940 temp_env::with_vars(
941 vec![
942 ("NAP_LORE_URL_BASE", Some("lore://custom:9999")),
943 ("NAP_WORKSPACE_ID", Some("custom-ws")),
944 ],
945 || {
946 let from_env = LoreBackend::from_env();
947 assert_eq!(from_env.remote_url, "lore://custom:9999");
948 assert_eq!(from_env.workspace_id, "custom-ws");
949 },
950 );
951 }
952
953 #[test]
954 fn test_from_env_default_without_env_vars() {
955 let temp_dir = tempfile::TempDir::new().unwrap();
957 let nap_dir_str = temp_dir.path().to_str().unwrap();
958
959 temp_env::with_vars(
960 vec![
961 ("NAP_LORE_URL_BASE", None::<&str>),
962 ("NAP_WORKSPACE_ID", None::<&str>),
963 ("NAP_DIR", Some(nap_dir_str)),
964 ],
965 || {
966 let backend = LoreBackend::from_env();
967 assert_eq!(backend.remote_url, "lore://localhost:41337");
968 assert_eq!(backend.workspace_id, "default");
969 },
970 );
971 }
972
973 #[test]
974 fn test_from_env_env_var_override() {
975 let temp_dir = tempfile::TempDir::new().unwrap();
977 let nap_dir_str = temp_dir.path().to_str().unwrap();
978
979 temp_env::with_vars(
980 vec![
981 ("NAP_LORE_URL_BASE", Some("lore://override:1234")),
982 ("NAP_WORKSPACE_ID", Some("override-ws")),
983 ("NAP_DIR", Some(nap_dir_str)),
984 ],
985 || {
986 let backend = LoreBackend::from_env();
987 assert_eq!(backend.remote_url, "lore://override:1234");
988 assert_eq!(backend.workspace_id, "override-ws");
989 },
990 );
991 }
992
993 #[test]
994 fn test_from_env_partial_env_override() {
995 let temp_dir = tempfile::TempDir::new().unwrap();
997 let nap_dir_str = temp_dir.path().to_str().unwrap();
998
999 temp_env::with_vars(
1000 vec![
1001 ("NAP_LORE_URL_BASE", Some("lore://partial:5678")),
1002 ("NAP_WORKSPACE_ID", None::<&str>),
1003 ("NAP_DIR", Some(nap_dir_str)),
1004 ],
1005 || {
1006 let backend = LoreBackend::from_env();
1007 assert_eq!(backend.remote_url, "lore://partial:5678");
1008 assert_eq!(backend.workspace_id, "default");
1009 },
1010 );
1011 }
1012
1013 #[test]
1014 fn test_from_env_provider_config() {
1015 let temp_dir = tempfile::TempDir::new().unwrap();
1017 let provider_config = temp_dir.path().join("provider.toml");
1018 std::fs::write(
1019 &provider_config,
1020 r#"
1021provider_type = "remote"
1022remote_url = "lore://provider:9999"
1023workspace_id = "provider-ws"
1024"#,
1025 )
1026 .unwrap();
1027
1028 let nap_dir_str = temp_dir.path().to_str().unwrap();
1029 temp_env::with_vars(
1030 vec![
1031 ("NAP_LORE_URL_BASE", None::<&str>),
1032 ("NAP_WORKSPACE_ID", None::<&str>),
1033 ("NAP_DIR", Some(nap_dir_str)),
1034 ],
1035 || {
1036 let backend = LoreBackend::from_env();
1037 assert_eq!(backend.remote_url, "lore://provider:9999");
1038 assert_eq!(backend.workspace_id, "provider-ws");
1039 },
1040 );
1041 }
1042
1043 #[test]
1044 fn test_from_env_nap_dir_with_tilde() {
1045 let _home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
1047 let temp_dir = tempfile::TempDir::new().unwrap();
1048 let nap_dir_str = temp_dir.path().to_str().unwrap();
1049
1050 temp_env::with_vars(
1051 vec![
1052 ("NAP_LORE_URL_BASE", None::<&str>),
1053 ("NAP_WORKSPACE_ID", None::<&str>),
1054 ("NAP_DIR", Some(nap_dir_str)),
1055 ],
1056 || {
1057 let backend = LoreBackend::from_env();
1058 assert_eq!(backend.remote_url, "lore://localhost:41337");
1060 assert_eq!(backend.workspace_id, "default");
1061 },
1062 );
1063 }
1064
1065 #[test]
1066 fn test_from_env_local_provider_config() {
1067 let temp_dir = tempfile::TempDir::new().unwrap();
1069 let provider_config = temp_dir.path().join("provider.toml");
1070 std::fs::write(
1071 &provider_config,
1072 r#"
1073provider_type = "local"
1074"#,
1075 )
1076 .unwrap();
1077
1078 let nap_dir_str = temp_dir.path().to_str().unwrap();
1079 temp_env::with_vars(
1080 vec![
1081 ("NAP_LORE_URL_BASE", None::<&str>),
1082 ("NAP_WORKSPACE_ID", None::<&str>),
1083 ("NAP_DIR", Some(nap_dir_str)),
1084 ],
1085 || {
1086 let backend = LoreBackend::from_env();
1087 assert_eq!(backend.remote_url, "lore://localhost:41337");
1088 assert_eq!(backend.workspace_id, "default");
1089 },
1090 );
1091 }
1092
1093 #[test]
1094 fn test_from_env_portals_cloud_provider_config() {
1095 let temp_dir = tempfile::TempDir::new().unwrap();
1097 let provider_config = temp_dir.path().join("provider.toml");
1098 std::fs::write(
1099 &provider_config,
1100 r#"
1101provider_type = "portals-cloud"
1102workspace_id = "cloud-ws"
1103"#,
1104 )
1105 .unwrap();
1106
1107 let nap_dir_str = temp_dir.path().to_str().unwrap();
1108 temp_env::with_vars(
1109 vec![
1110 ("NAP_LORE_URL_BASE", None::<&str>),
1111 ("NAP_WORKSPACE_ID", None::<&str>),
1112 ("NAP_DIR", Some(nap_dir_str)),
1113 ],
1114 || {
1115 let backend = LoreBackend::from_env();
1116 assert_eq!(backend.remote_url, PORTALS_CLOUD_URL);
1117 assert_eq!(backend.workspace_id, "cloud-ws");
1118 },
1119 );
1120 }
1121
1122 #[test]
1123 fn test_from_env_portals_cloud_default_workspace() {
1124 let temp_dir = tempfile::TempDir::new().unwrap();
1126 let provider_config = temp_dir.path().join("provider.toml");
1127 std::fs::write(
1128 &provider_config,
1129 r#"
1130provider_type = "portals-cloud"
1131"#,
1132 )
1133 .unwrap();
1134
1135 let nap_dir_str = temp_dir.path().to_str().unwrap();
1136 temp_env::with_vars(
1137 vec![
1138 ("NAP_LORE_URL_BASE", None::<&str>),
1139 ("NAP_WORKSPACE_ID", None::<&str>),
1140 ("NAP_DIR", Some(nap_dir_str)),
1141 ],
1142 || {
1143 let backend = LoreBackend::from_env();
1144 assert_eq!(backend.remote_url, PORTALS_CLOUD_URL);
1145 assert_eq!(backend.workspace_id, "default");
1146 },
1147 );
1148 }
1149
1150 #[test]
1151 fn test_from_env_unknown_provider_type() {
1152 let temp_dir = tempfile::TempDir::new().unwrap();
1154 let provider_config = temp_dir.path().join("provider.toml");
1155 std::fs::write(
1156 &provider_config,
1157 r#"
1158provider_type = "unknown-provider"
1159"#,
1160 )
1161 .unwrap();
1162
1163 let nap_dir_str = temp_dir.path().to_str().unwrap();
1164 temp_env::with_vars(
1165 vec![
1166 ("NAP_LORE_URL_BASE", None::<&str>),
1167 ("NAP_WORKSPACE_ID", None::<&str>),
1168 ("NAP_DIR", Some(nap_dir_str)),
1169 ],
1170 || {
1171 let backend = LoreBackend::from_env();
1172 assert_eq!(backend.remote_url, "lore://localhost:41337");
1173 assert_eq!(backend.workspace_id, "default");
1174 },
1175 );
1176 }
1177
1178 #[test]
1179 fn test_repo_url_joining() {
1180 let backend = LoreBackend::new("lore://localhost:8700", "ws");
1181 assert_eq!(backend.repo_url("my-repo"), "lore://localhost:8700/my-repo");
1182
1183 let backend2 = LoreBackend::new("lore://host:8700/", "ws");
1185 assert_eq!(backend2.repo_url("foo"), "lore://host:8700/foo");
1186 }
1187
1188 #[test]
1189 fn test_list_branches_empty_json() {
1190 }
1195
1196 #[test]
1197 fn test_commit_parses_signature_from_stdout() {
1198 let sample = "Created revision a1b2c3d4 (#42)";
1202 let signature = sample
1203 .strip_prefix("Created revision ")
1204 .and_then(|s| s.split_whitespace().next())
1205 .unwrap_or(sample);
1206 assert_eq!(signature, "a1b2c3d4");
1207 }
1208
1209 #[test]
1212 fn test_commit_info_from_lore_revision() {
1213 let info = CommitInfo::from_lore_revision(
1214 "sig123",
1215 Some("sig122"),
1216 "alice",
1217 "feat: add manifest",
1218 "2026-06-30T12:00:00Z",
1219 );
1220 assert_eq!(info.id, "sig123");
1221 assert_eq!(info.parent.as_deref(), Some("sig122"));
1222 assert_eq!(info.author, "alice");
1223 assert_eq!(info.message, "feat: add manifest");
1224 assert_eq!(info.timestamp, "2026-06-30T12:00:00Z");
1225 }
1226
1227 #[test]
1228 fn test_commit_info_default_timestamp() {
1229 let info = CommitInfo::from_lore_revision("sig", None, "bob", "msg", "");
1231 assert!(
1232 info.timestamp.contains('T') || info.timestamp.contains('Z'),
1233 "expected RFC 3339 timestamp, got: {}",
1234 info.timestamp
1235 );
1236 }
1237}