1use std::path::{Path, PathBuf};
34use std::process::Command;
35use std::time::Instant;
36
37use crate::error::NapError;
38use crate::vcs::{CommitInfo, VcsBackend};
39
40#[derive(serde::Deserialize)]
42struct ProviderConfigToml {
43 provider_type: String,
44 remote_url: Option<String>,
45 workspace_id: Option<String>,
46}
47
48const PORTALS_CLOUD_URL: &str = "lore://cloud.portals.sh:41337";
50
51pub struct LoreProcessRunner;
68
69impl LoreProcessRunner {
70 pub fn binary() -> String {
73 std::env::var("NAPLORE_CLI").unwrap_or_else(|_| "lore".to_string())
74 }
75
76 pub fn run<I, S>(args: I, cwd: Option<&Path>) -> Result<String, NapError>
80 where
81 I: IntoIterator<Item = S>,
82 S: AsRef<std::ffi::OsStr>,
83 {
84 let args_vec: Vec<String> = args
85 .into_iter()
86 .map(|s| s.as_ref().to_string_lossy().into_owned())
87 .collect();
88 let bin = Self::binary();
89 let mut cmd = Command::new(&bin);
90 cmd.args(&args_vec);
91
92 if let Some(dir) = cwd {
93 cmd.current_dir(dir);
94 }
95
96 let start = Instant::now();
97 let output = cmd.output().map_err(|e| {
99 NapError::VcsError(format!(
100 "failed to execute `{}`: {}. Is `{}` installed and on $PATH?",
101 bin, e, bin
102 ))
103 })?;
104 let duration = start.elapsed();
105 if duration > std::time::Duration::from_secs(5) {
106 tracing::warn!(
107 duration_ms = duration.as_millis(),
108 command = format!("{} {:?}", bin, args_vec),
109 "lore command took > 5s — check Lore server health"
110 );
111 }
112
113 if output.status.success() {
114 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
115 return Ok(stdout);
116 }
117
118 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
120 let exit_code = output.status.code().unwrap_or(-1);
121
122 let nap_err = match exit_code {
126 1 => {
127 if stderr.contains("not a lore workspace")
129 || stderr.contains("not an initialised lore workspace")
130 {
131 NapError::VcsError(format!(
132 "not a lore workspace at {:?}",
133 cwd.unwrap_or(Path::new("."))
134 ))
135 } else if stderr.contains("not found") {
136 NapError::VcsError(format!("path not found in lore workspace: {}", stderr))
137 } else {
138 NapError::VcsError(format!(
139 "lore CLI exited with code {}: {}",
140 exit_code, stderr
141 ))
142 }
143 }
144 64..=126 => {
145 NapError::VcsError(format!(
147 "lore CLI configuration error ({}): {}",
148 exit_code, stderr
149 ))
150 }
151 _ => NapError::VcsError(format!(
152 "lore CLI exited with code {}: {}",
153 exit_code, stderr
154 )),
155 };
156
157 Err(nap_err)
158 }
159}
160
161#[derive(Debug, Clone)]
174pub struct LoreBackend {
175 remote_url: String,
177 workspace_id: String,
179}
180
181impl LoreBackend {
182 pub fn new(remote_url: &str, workspace_id: &str) -> Self {
187 Self {
188 remote_url: remote_url.to_string(),
189 workspace_id: workspace_id.to_string(),
190 }
191 }
192
193 pub fn remote_url(&self) -> &str {
194 &self.remote_url
195 }
196
197 pub fn clone_repo(url: &str, dest: &Path) -> Result<(), NapError> {
203 LoreProcessRunner::run(
204 [
205 "clone",
206 url,
207 dest.to_str().unwrap_or("."),
208 "--non-interactive",
209 ],
210 None,
211 )?;
212 Ok(())
213 }
214
215 pub fn from_env() -> Self {
228 if let Ok(nap_dir) = std::env::var("NAP_DIR") {
230 let manager = crate::server::manager::ServerManager::new(Path::new(&nap_dir));
231 let _ = tokio::runtime::Handle::try_current().map(|handle| {
232 handle.block_on(async {
233 let _ = manager.ensure_running().await;
234 });
235 });
236 }
237
238 let url_from_env = std::env::var("NAP_LORE_URL_BASE").ok();
240 let workspace_from_env = std::env::var("NAP_WORKSPACE_ID").ok();
241
242 if url_from_env.is_some() || workspace_from_env.is_some() {
243 let base = url_from_env.unwrap_or_else(|| "lore://localhost:41337".to_string());
244 let workspace_id = workspace_from_env.unwrap_or_else(|| "default".to_string());
245 tracing::debug!(
246 url_base = %base,
247 workspace_id = %workspace_id,
248 "LoreBackend::from_env using environment variables (override)"
249 );
250 return Self {
251 remote_url: base,
252 workspace_id,
253 };
254 }
255
256 let nap_dir = if let Ok(nap_dir_str) = std::env::var("NAP_DIR") {
258 let path = PathBuf::from(&nap_dir_str);
260 if let Some(s) = path.to_str() {
261 if let Some(stripped) = s.strip_prefix('~') {
262 let home = std::env::var("HOME")
263 .or_else(|_| std::env::var("USERPROFILE"))
264 .unwrap_or_else(|_| ".".to_string());
265 PathBuf::from(home).join(stripped.trim_start_matches('/'))
266 } else {
267 path
268 }
269 } else {
270 path
271 }
272 } else {
273 let home = std::env::var("HOME")
275 .or_else(|_| std::env::var("USERPROFILE"))
276 .unwrap_or_else(|_| ".".to_string());
277 PathBuf::from(home).join(".nap")
278 };
279
280 let provider_config_path = nap_dir.join("provider.toml");
281 if provider_config_path.exists()
282 && let Ok(config_content) = std::fs::read_to_string(&provider_config_path)
283 && let Ok(config) = toml::from_str::<ProviderConfigToml>(&config_content)
284 {
285 match config.provider_type.as_str() {
286 "local" => {
287 tracing::debug!(
289 url_base = "lore://localhost:41337",
290 workspace_id = "default",
291 "LoreBackend::from_env using local provider configuration"
292 );
293 return Self {
294 remote_url: "lore://localhost:41337".to_string(),
295 workspace_id: "default".to_string(),
296 };
297 }
298 "remote" => {
299 if let (Some(url), Some(workspace)) = (config.remote_url, config.workspace_id) {
301 tracing::debug!(
302 url_base = %url,
303 workspace_id = %workspace,
304 "LoreBackend::from_env using remote provider configuration"
305 );
306 return Self {
307 remote_url: url,
308 workspace_id: workspace,
309 };
310 }
311 }
312 "portals-cloud" => {
313 let workspace_id = config.workspace_id.unwrap_or_else(|| "default".to_string());
315 tracing::debug!(
316 url_base = %PORTALS_CLOUD_URL,
317 workspace_id = %workspace_id,
318 "LoreBackend::from_env using portals-cloud provider configuration"
319 );
320 return Self {
321 remote_url: PORTALS_CLOUD_URL.to_string(),
322 workspace_id,
323 };
324 }
325 _ => {
326 tracing::debug!(
327 provider_type = %config.provider_type,
328 "Unknown provider type, falling back to defaults"
329 );
330 }
331 }
332 }
333
334 let base = "lore://localhost:41337".to_string();
336 let workspace_id = "default".to_string();
337 tracing::debug!(
338 url_base = %base,
339 workspace_id = %workspace_id,
340 "LoreBackend::from_env using defaults"
341 );
342 Self {
343 remote_url: base,
344 workspace_id,
345 }
346 }
347
348 pub fn from_provider(url_base: &str, workspace_id: &str) -> Self {
352 tracing::debug!(
353 url_base = %url_base,
354 workspace_id = %workspace_id,
355 "Creating LoreBackend from provider configuration"
356 );
357
358 Self {
359 remote_url: url_base.to_string(),
360 workspace_id: workspace_id.to_string(),
361 }
362 }
363
364 fn repo_url(&self, repo_id: &str) -> String {
366 format!("{}/{}", self.remote_url.trim_end_matches('/'), repo_id)
367 }
368}
369
370impl VcsBackend for LoreBackend {
371 fn remote_url_base(&self) -> Result<String, NapError> {
373 Ok(self.remote_url.clone())
374 }
375
376 fn init(&self, path: &Path) -> Result<(), NapError> {
378 let repo_id = path
387 .file_name()
388 .and_then(|n| n.to_str())
389 .unwrap_or("nap-repo");
390
391 let url = self.repo_url(repo_id);
392 let path_str = path.to_str().unwrap_or(".");
393
394 let server_path = path
396 .parent()
397 .unwrap_or(path)
398 .join(".lore-server")
399 .join(repo_id);
400
401 LoreProcessRunner::run(
403 [
404 "repository",
405 "create",
406 &url,
407 "--id",
408 &self.workspace_id,
409 "--repository",
410 server_path.to_str().unwrap_or("."),
411 "--non-interactive",
412 ],
413 None,
414 )
415 .map_err(|e| {
416 NapError::VcsError(format!("failed to create lore repository '{}': {}", url, e))
417 })?;
418
419 LoreProcessRunner::run(["clone", &url, path_str, "--non-interactive"], None).map_err(
421 |e| {
422 NapError::VcsError(format!(
423 "failed to clone lore repository to {:?}: {}",
424 path, e
425 ))
426 },
427 )?;
428
429 Ok(())
430 }
431
432 fn commit(&self, path: &Path, message: &str, author: &str) -> Result<String, NapError> {
434 LoreProcessRunner::run(["stage", "--scan", ".", "--non-interactive"], Some(path))?;
437
438 let stdout = LoreProcessRunner::run(
440 [
441 "revision",
442 "commit",
443 message,
444 "--identity",
445 author,
446 "--non-interactive",
447 ],
448 Some(path),
449 )?;
450
451 let signature = stdout
454 .lines()
455 .find_map(|line| {
456 line.strip_prefix("Signature :")
457 .or_else(|| line.strip_prefix("Signature:"))
458 })
459 .map(|s| s.trim().to_string())
460 .unwrap_or_else(|| {
461 stdout
463 .lines()
464 .next()
465 .unwrap_or(&stdout)
466 .trim()
467 .strip_prefix("Created revision ")
468 .and_then(|s| s.split_whitespace().next())
469 .map(|s| s.to_string())
470 .unwrap_or_else(|| stdout.trim().to_string())
471 });
472
473 Ok(signature)
474 }
475
476 fn read_file_at_ref(
478 &self,
479 repo_path: &Path,
480 file_path: &str,
481 _reference: Option<&str>,
482 ) -> Result<String, NapError> {
483 let full_path = repo_path.join(file_path);
486 std::fs::read_to_string(&full_path).map_err(|e| {
487 NapError::VcsError(format!("failed to read {}: {}", full_path.display(), e))
488 })
489 }
490
491 fn log(
493 &self,
494 path: &Path,
495 _file: Option<&str>,
496 limit: usize,
497 ) -> Result<Vec<CommitInfo>, NapError> {
498 let limit_str = limit.to_string();
499 let args = vec!["history", &limit_str, "--non-interactive"];
500
501 let stdout = LoreProcessRunner::run(&args, Some(path))?;
502
503 if stdout.trim().is_empty() {
504 return Ok(Vec::new());
505 }
506
507 let mut commits = Vec::new();
516 let mut current_signature = String::new();
517 let mut current_author = String::new();
518 let mut current_message = String::new();
519 let mut current_timestamp = String::new();
520 let mut current_parent: Option<String> = None;
521 let mut in_message = false;
522
523 for line in stdout.lines() {
524 let trimmed = line.trim();
525 if trimmed.starts_with("Signature :") || trimmed.starts_with("Signature:") {
526 if !current_signature.is_empty() {
528 commits.push(CommitInfo {
529 id: std::mem::take(&mut current_signature),
530 parent: current_parent.take(),
531 author: std::mem::take(&mut current_author),
532 message: std::mem::take(&mut current_message),
533 timestamp: std::mem::take(&mut current_timestamp),
534 });
535 }
536 current_signature = trimmed
537 .strip_prefix("Signature :")
538 .or_else(|| trimmed.strip_prefix("Signature:"))
539 .unwrap_or("")
540 .trim()
541 .to_string();
542 in_message = false;
543 } else if trimmed.starts_with("Date :") || trimmed.starts_with("Date:") {
544 current_timestamp = trimmed
545 .split_once(':')
546 .map(|(_, v)| v.trim().to_string())
547 .unwrap_or_default();
548 in_message = true;
549 } else if trimmed.starts_with("Creator :") || trimmed.starts_with("Creator:") {
550 current_author = trimmed
551 .split_once(':')
552 .map(|(_, v)| v.trim().to_string())
553 .unwrap_or_default();
554 in_message = false;
555 } else if trimmed.starts_with("Revision :")
556 || trimmed.starts_with("Revision:")
557 || trimmed.starts_with("Branch :")
558 || trimmed.starts_with("Branch:")
559 || trimmed.starts_with("Committer :")
560 || trimmed.starts_with("Committer:")
561 {
562 in_message = false;
563 } else if in_message {
564 if trimmed.is_empty() || trimmed == "Commit succeeded" {
565 in_message = false;
566 } else {
567 if !current_message.is_empty() {
568 current_message.push('\n');
569 }
570 current_message.push_str(trimmed);
571 }
572 }
573 }
574 if !current_signature.is_empty() {
576 commits.push(CommitInfo {
577 id: current_signature,
578 parent: current_parent,
579 author: current_author,
580 message: current_message,
581 timestamp: current_timestamp,
582 });
583 }
584
585 Ok(commits)
586 }
587
588 fn create_branch(&self, path: &Path, name: &str) -> Result<(), NapError> {
590 LoreProcessRunner::run(["branch", "create", name, "--non-interactive"], Some(path))?;
591 Ok(())
592 }
593
594 fn switch_branch(&self, path: &Path, name: &str) -> Result<(), NapError> {
595 LoreProcessRunner::run(["branch", "switch", name, "--non-interactive"], Some(path))?;
596 Ok(())
597 }
598
599 fn current_branch(&self, path: &Path) -> Result<String, NapError> {
600 let stdout = LoreProcessRunner::run(["branch", "show", "--non-interactive"], Some(path))?;
601 Ok(stdout.trim().to_string())
602 }
603
604 fn list_branches(&self, path: &Path) -> Result<Vec<String>, NapError> {
605 let stdout = LoreProcessRunner::run(["branch", "list", "--non-interactive"], Some(path))?;
606 if stdout.is_empty() {
607 return Ok(Vec::new());
608 }
609 let mut branches = Vec::new();
616 let mut in_local = false;
617 for line in stdout.lines() {
618 let trimmed = line.trim();
619 if trimmed.starts_with("Local branches") {
620 in_local = true;
621 continue;
622 }
623 if trimmed.starts_with("Remote branches") {
624 in_local = false;
625 continue;
626 }
627 if in_local && !trimmed.is_empty() {
628 let name = trimmed.strip_prefix("* ").unwrap_or(trimmed);
630 branches.push(name.to_string());
631 }
632 }
633 Ok(branches)
634 }
635
636 fn head_hash(&self, path: &Path) -> Result<String, NapError> {
638 let stdout = LoreProcessRunner::run(["history", "1", "--non-interactive"], Some(path))?;
639
640 if stdout.trim().is_empty() {
641 return Err(NapError::VcsError(
642 "no commits in lore workspace".to_string(),
643 ));
644 }
645
646 stdout
648 .lines()
649 .find_map(|line| {
650 line.trim()
651 .strip_prefix("Signature :")
652 .or_else(|| line.trim().strip_prefix("Signature:"))
653 })
654 .map(|s| s.trim().to_string())
655 .ok_or_else(|| {
656 NapError::VcsError(format!(
657 "failed to parse signature from lore history: {stdout}"
658 ))
659 })
660 }
661
662 fn revert(&self, path: &Path, commit_hash: &str) -> Result<String, NapError> {
663 let stdout = LoreProcessRunner::run(
664 ["revision", "revert", commit_hash, "--non-interactive"],
665 Some(path),
666 )?;
667 let signature = stdout
669 .trim()
670 .strip_prefix("Created revert revision ")
671 .unwrap_or(stdout.trim());
672 Ok(signature.to_string())
673 }
674
675 fn resolve_branch_head(&self, path: &Path, branch: &str) -> Result<String, NapError> {
676 let stdout = LoreProcessRunner::run(
677 ["history", "1", "--branch", branch, "--non-interactive"],
678 Some(path),
679 )?;
680
681 if stdout.trim().is_empty() {
682 return Err(NapError::VcsError(format!(
683 "no commits found on branch '{branch}'"
684 )));
685 }
686
687 stdout
689 .lines()
690 .find_map(|line| {
691 line.trim()
692 .strip_prefix("Signature :")
693 .or_else(|| line.trim().strip_prefix("Signature:"))
694 })
695 .map(|s| s.trim().to_string())
696 .ok_or_else(|| {
697 NapError::VcsError(format!(
698 "failed to parse signature from lore history on branch '{branch}': {stdout}"
699 ))
700 })
701 }
702
703 fn add_remote(&self, path: &Path, name: &str, url: &str) -> Result<(), NapError> {
705 LoreProcessRunner::run(
706 [
707 "repository",
708 "add",
709 url,
710 "--alias",
711 name,
712 "--non-interactive",
713 ],
714 Some(path),
715 )?;
716 Ok(())
717 }
718
719 fn remove_remote(&self, path: &Path, name: &str) -> Result<(), NapError> {
720 LoreProcessRunner::run(
721 ["repository", "remove", "--alias", name, "--non-interactive"],
722 Some(path),
723 )?;
724 Ok(())
725 }
726
727 fn list_remotes(&self, path: &Path) -> Result<Vec<(String, String)>, NapError> {
728 let stdout = LoreProcessRunner::run(
729 [
730 "repository",
731 "list",
732 "--format",
733 "json",
734 "--non-interactive",
735 ],
736 Some(path),
737 )?;
738
739 if stdout.is_empty() || stdout == "[]" || stdout == "null" {
740 return Ok(Vec::new());
741 }
742
743 #[derive(serde::Deserialize)]
745 struct RemoteEntry {
746 #[allow(dead_code)]
747 name: String,
748 #[allow(dead_code)]
749 url: String,
750 }
751 let entries: Vec<RemoteEntry> = serde_json::from_str(&stdout).map_err(|e| {
752 NapError::VcsError(format!(
753 "failed to parse lore repository list JSON: {}. Raw: {}",
754 e, stdout
755 ))
756 })?;
757
758 let pairs: Vec<(String, String)> = entries.into_iter().map(|e| (e.name, e.url)).collect();
759 Ok(pairs)
760 }
761
762 fn push(
764 &self,
765 path: &Path,
766 _remote: Option<&str>,
767 branch: Option<&str>,
768 ) -> Result<(), NapError> {
769 let branch_name = match branch {
772 Some(b) => b.to_string(),
773 None => self
774 .current_branch(path)
775 .unwrap_or_else(|_| "main".to_string()),
776 };
777
778 let args = vec![
780 "branch",
781 "push",
782 &branch_name,
783 "--fast-forward-merge",
784 "--non-interactive",
785 ];
786 LoreProcessRunner::run(&args, Some(path))?;
787
788 Ok(())
789 }
790
791 fn pull(
792 &self,
793 path: &Path,
794 _remote: Option<&str>,
795 _branch: Option<&str>,
796 ) -> Result<(), NapError> {
797 let args = vec!["sync", "--non-interactive", "--reset"];
799 LoreProcessRunner::run(&args, Some(path))?;
800
801 Ok(())
802 }
803}
804
805#[cfg(all(test, feature = "lore-integration"))]
810mod tests {
811 use super::*;
812
813 #[test]
816 fn test_binary_default() {
817 assert_eq!(LoreProcessRunner::binary(), "lore");
818 }
819
820 #[test]
821 fn test_binary_from_env() {
822 temp_env::with_var("NAPLORE_CLI", Some("/custom/lore"), || {
823 assert_eq!(LoreProcessRunner::binary(), "/custom/lore");
824 });
825 }
826
827 #[test]
828 fn test_run_captures_stdout() {
829 temp_env::with_var("NAPLORE_CLI", Some("lore-nonexistent-binary-12345"), || {
833 let result = LoreProcessRunner::run(["--version"], None);
834 assert!(result.is_err());
835 let err = result.unwrap_err().to_string();
836 assert!(
837 err.contains("lore-nonexistent-binary-12345"),
838 "error: {}",
839 err
840 );
841 });
842 }
843
844 #[test]
847 fn test_new_and_from_env() {
848 let backend = LoreBackend::new("lore://myhost:8700", "test-workspace");
849 assert_eq!(backend.remote_url, "lore://myhost:8700");
850 assert_eq!(backend.workspace_id, "test-workspace");
851
852 temp_env::with_vars(
853 vec![
854 ("NAP_LORE_URL_BASE", Some("lore://custom:9999")),
855 ("NAP_WORKSPACE_ID", Some("custom-ws")),
856 ],
857 || {
858 let from_env = LoreBackend::from_env();
859 assert_eq!(from_env.remote_url, "lore://custom:9999");
860 assert_eq!(from_env.workspace_id, "custom-ws");
861 },
862 );
863 }
864
865 #[test]
866 fn test_from_env_default_without_env_vars() {
867 let temp_dir = tempfile::TempDir::new().unwrap();
869 let nap_dir_str = temp_dir.path().to_str().unwrap();
870
871 temp_env::with_vars(
872 vec![
873 ("NAP_LORE_URL_BASE", None::<&str>),
874 ("NAP_WORKSPACE_ID", None::<&str>),
875 ("NAP_DIR", Some(nap_dir_str)),
876 ],
877 || {
878 let backend = LoreBackend::from_env();
879 assert_eq!(backend.remote_url, "lore://localhost:41337");
880 assert_eq!(backend.workspace_id, "default");
881 },
882 );
883 }
884
885 #[test]
886 fn test_from_env_env_var_override() {
887 let temp_dir = tempfile::TempDir::new().unwrap();
889 let nap_dir_str = temp_dir.path().to_str().unwrap();
890
891 temp_env::with_vars(
892 vec![
893 ("NAP_LORE_URL_BASE", Some("lore://override:1234")),
894 ("NAP_WORKSPACE_ID", Some("override-ws")),
895 ("NAP_DIR", Some(nap_dir_str)),
896 ],
897 || {
898 let backend = LoreBackend::from_env();
899 assert_eq!(backend.remote_url, "lore://override:1234");
900 assert_eq!(backend.workspace_id, "override-ws");
901 },
902 );
903 }
904
905 #[test]
906 fn test_from_env_partial_env_override() {
907 let temp_dir = tempfile::TempDir::new().unwrap();
909 let nap_dir_str = temp_dir.path().to_str().unwrap();
910
911 temp_env::with_vars(
912 vec![
913 ("NAP_LORE_URL_BASE", Some("lore://partial:5678")),
914 ("NAP_WORKSPACE_ID", None::<&str>),
915 ("NAP_DIR", Some(nap_dir_str)),
916 ],
917 || {
918 let backend = LoreBackend::from_env();
919 assert_eq!(backend.remote_url, "lore://partial:5678");
920 assert_eq!(backend.workspace_id, "default");
921 },
922 );
923 }
924
925 #[test]
926 fn test_from_env_provider_config() {
927 let temp_dir = tempfile::TempDir::new().unwrap();
929 let provider_config = temp_dir.path().join("provider.toml");
930 std::fs::write(
931 &provider_config,
932 r#"
933provider_type = "remote"
934remote_url = "lore://provider:9999"
935workspace_id = "provider-ws"
936"#,
937 )
938 .unwrap();
939
940 let nap_dir_str = temp_dir.path().to_str().unwrap();
941 temp_env::with_vars(
942 vec![
943 ("NAP_LORE_URL_BASE", None::<&str>),
944 ("NAP_WORKSPACE_ID", None::<&str>),
945 ("NAP_DIR", Some(nap_dir_str)),
946 ],
947 || {
948 let backend = LoreBackend::from_env();
949 assert_eq!(backend.remote_url, "lore://provider:9999");
950 assert_eq!(backend.workspace_id, "provider-ws");
951 },
952 );
953 }
954
955 #[test]
956 fn test_from_env_nap_dir_with_tilde() {
957 let _home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
959 let temp_dir = tempfile::TempDir::new().unwrap();
960 let nap_dir_str = temp_dir.path().to_str().unwrap();
961
962 temp_env::with_vars(
963 vec![
964 ("NAP_LORE_URL_BASE", None::<&str>),
965 ("NAP_WORKSPACE_ID", None::<&str>),
966 ("NAP_DIR", Some(nap_dir_str)),
967 ],
968 || {
969 let backend = LoreBackend::from_env();
970 assert_eq!(backend.remote_url, "lore://localhost:41337");
972 assert_eq!(backend.workspace_id, "default");
973 },
974 );
975 }
976
977 #[test]
978 fn test_from_env_local_provider_config() {
979 let temp_dir = tempfile::TempDir::new().unwrap();
981 let provider_config = temp_dir.path().join("provider.toml");
982 std::fs::write(
983 &provider_config,
984 r#"
985provider_type = "local"
986"#,
987 )
988 .unwrap();
989
990 let nap_dir_str = temp_dir.path().to_str().unwrap();
991 temp_env::with_vars(
992 vec![
993 ("NAP_LORE_URL_BASE", None::<&str>),
994 ("NAP_WORKSPACE_ID", None::<&str>),
995 ("NAP_DIR", Some(nap_dir_str)),
996 ],
997 || {
998 let backend = LoreBackend::from_env();
999 assert_eq!(backend.remote_url, "lore://localhost:41337");
1000 assert_eq!(backend.workspace_id, "default");
1001 },
1002 );
1003 }
1004
1005 #[test]
1006 fn test_from_env_portals_cloud_provider_config() {
1007 let temp_dir = tempfile::TempDir::new().unwrap();
1009 let provider_config = temp_dir.path().join("provider.toml");
1010 std::fs::write(
1011 &provider_config,
1012 r#"
1013provider_type = "portals-cloud"
1014workspace_id = "cloud-ws"
1015"#,
1016 )
1017 .unwrap();
1018
1019 let nap_dir_str = temp_dir.path().to_str().unwrap();
1020 temp_env::with_vars(
1021 vec![
1022 ("NAP_LORE_URL_BASE", None::<&str>),
1023 ("NAP_WORKSPACE_ID", None::<&str>),
1024 ("NAP_DIR", Some(nap_dir_str)),
1025 ],
1026 || {
1027 let backend = LoreBackend::from_env();
1028 assert_eq!(backend.remote_url, PORTALS_CLOUD_URL);
1029 assert_eq!(backend.workspace_id, "cloud-ws");
1030 },
1031 );
1032 }
1033
1034 #[test]
1035 fn test_from_env_portals_cloud_default_workspace() {
1036 let temp_dir = tempfile::TempDir::new().unwrap();
1038 let provider_config = temp_dir.path().join("provider.toml");
1039 std::fs::write(
1040 &provider_config,
1041 r#"
1042provider_type = "portals-cloud"
1043"#,
1044 )
1045 .unwrap();
1046
1047 let nap_dir_str = temp_dir.path().to_str().unwrap();
1048 temp_env::with_vars(
1049 vec![
1050 ("NAP_LORE_URL_BASE", None::<&str>),
1051 ("NAP_WORKSPACE_ID", None::<&str>),
1052 ("NAP_DIR", Some(nap_dir_str)),
1053 ],
1054 || {
1055 let backend = LoreBackend::from_env();
1056 assert_eq!(backend.remote_url, PORTALS_CLOUD_URL);
1057 assert_eq!(backend.workspace_id, "default");
1058 },
1059 );
1060 }
1061
1062 #[test]
1063 fn test_from_env_unknown_provider_type() {
1064 let temp_dir = tempfile::TempDir::new().unwrap();
1066 let provider_config = temp_dir.path().join("provider.toml");
1067 std::fs::write(
1068 &provider_config,
1069 r#"
1070provider_type = "unknown-provider"
1071"#,
1072 )
1073 .unwrap();
1074
1075 let nap_dir_str = temp_dir.path().to_str().unwrap();
1076 temp_env::with_vars(
1077 vec![
1078 ("NAP_LORE_URL_BASE", None::<&str>),
1079 ("NAP_WORKSPACE_ID", None::<&str>),
1080 ("NAP_DIR", Some(nap_dir_str)),
1081 ],
1082 || {
1083 let backend = LoreBackend::from_env();
1084 assert_eq!(backend.remote_url, "lore://localhost:41337");
1085 assert_eq!(backend.workspace_id, "default");
1086 },
1087 );
1088 }
1089
1090 #[test]
1091 fn test_repo_url_joining() {
1092 let backend = LoreBackend::new("lore://localhost:8700", "ws");
1093 assert_eq!(backend.repo_url("my-repo"), "lore://localhost:8700/my-repo");
1094
1095 let backend2 = LoreBackend::new("lore://host:8700/", "ws");
1097 assert_eq!(backend2.repo_url("foo"), "lore://host:8700/foo");
1098 }
1099
1100 #[test]
1101 fn test_list_branches_empty_json() {
1102 }
1107
1108 #[test]
1109 fn test_commit_parses_signature_from_stdout() {
1110 let sample = "Created revision a1b2c3d4 (#42)";
1114 let signature = sample
1115 .strip_prefix("Created revision ")
1116 .and_then(|s| s.split_whitespace().next())
1117 .unwrap_or(sample);
1118 assert_eq!(signature, "a1b2c3d4");
1119 }
1120
1121 #[test]
1124 fn test_commit_info_from_lore_revision() {
1125 let info = CommitInfo::from_lore_revision(
1126 "sig123",
1127 Some("sig122"),
1128 "alice",
1129 "feat: add manifest",
1130 "2026-06-30T12:00:00Z",
1131 );
1132 assert_eq!(info.id, "sig123");
1133 assert_eq!(info.parent.as_deref(), Some("sig122"));
1134 assert_eq!(info.author, "alice");
1135 assert_eq!(info.message, "feat: add manifest");
1136 assert_eq!(info.timestamp, "2026-06-30T12:00:00Z");
1137 }
1138
1139 #[test]
1140 fn test_commit_info_default_timestamp() {
1141 let info = CommitInfo::from_lore_revision("sig", None, "bob", "msg", "");
1143 assert!(
1144 info.timestamp.contains('T') || info.timestamp.contains('Z'),
1145 "expected RFC 3339 timestamp, got: {}",
1146 info.timestamp
1147 );
1148 }
1149}