1use std::collections::BTreeMap;
34use std::path::{Path, PathBuf};
35use std::process::Command;
36use std::time::Instant;
37
38use crate::error::NapError;
39use crate::vcs::{CommitInfo, VcsBackend, VcsContentAddress, VcsRepositoryDescriptor};
40
41#[derive(serde::Deserialize)]
43struct ProviderConfigToml {
44 provider_type: String,
45 remote_url: Option<String>,
46 workspace_id: Option<String>,
47}
48
49use crate::provider::portals_cloud::PORTALS_CLOUD_URL;
51
52pub struct LoreProcessRunner;
69
70impl LoreProcessRunner {
71 pub fn binary() -> String {
74 std::env::var("NAPLORE_CLI").unwrap_or_else(|_| "lore".to_string())
75 }
76
77 pub fn run<I, S>(args: I, cwd: Option<&Path>) -> Result<String, NapError>
81 where
82 I: IntoIterator<Item = S>,
83 S: AsRef<std::ffi::OsStr>,
84 {
85 let args_vec: Vec<String> = args
86 .into_iter()
87 .map(|s| s.as_ref().to_string_lossy().into_owned())
88 .collect();
89 let bin = Self::binary();
90 let mut cmd = Command::new(&bin);
91 cmd.args(&args_vec);
92
93 if let Some(dir) = cwd {
94 cmd.current_dir(dir);
95 }
96
97 let start = Instant::now();
98 let output = cmd.output().map_err(|e| {
100 NapError::VcsError(format!(
101 "failed to execute `{}`: {}. Is `{}` installed and on $PATH?",
102 bin, e, bin
103 ))
104 })?;
105 let duration = start.elapsed();
106 if duration > std::time::Duration::from_secs(5) {
107 tracing::warn!(
108 duration_ms = duration.as_millis(),
109 command = format!("{} {:?}", bin, args_vec),
110 "lore command took > 5s — check Lore server health"
111 );
112 }
113
114 if output.status.success() {
115 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
116 return Ok(stdout);
117 }
118
119 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
121 let exit_code = output.status.code().unwrap_or(-1);
122
123 let nap_err = match exit_code {
127 1 => {
128 if stderr.contains("not authenticated")
130 || stderr.contains("authentication required")
131 || stderr.contains("Unauthenticated")
132 {
133 NapError::VcsError(
134 "Portals Cloud authentication is required; run `nap auth login` in an interactive terminal and retry"
135 .to_string(),
136 )
137 } else if stderr.contains("not a lore workspace")
138 || stderr.contains("not an initialised lore workspace")
139 {
140 NapError::VcsError(format!(
141 "not a lore workspace at {:?}",
142 cwd.unwrap_or(Path::new("."))
143 ))
144 } else if stderr.contains("not found") {
145 NapError::VcsError(format!("path not found in lore workspace: {}", stderr))
146 } else {
147 NapError::VcsError(format!(
148 "lore CLI exited with code {}: {}",
149 exit_code, stderr
150 ))
151 }
152 }
153 64..=126 => {
154 NapError::VcsError(format!(
156 "lore CLI configuration error ({}): {}",
157 exit_code, stderr
158 ))
159 }
160 _ => NapError::VcsError(format!(
161 "lore CLI exited with code {}: {}",
162 exit_code, stderr
163 )),
164 };
165
166 Err(nap_err)
167 }
168}
169
170fn parse_lore_event_data(stdout: &str, tag: &str) -> Result<serde_json::Value, String> {
171 let mut match_data = None;
172 for line in stdout.lines().filter(|line| !line.trim().is_empty()) {
173 let event: serde_json::Value =
174 serde_json::from_str(line).map_err(|e| format!("invalid Lore JSON event: {e}"))?;
175 if event.get("tagName").and_then(serde_json::Value::as_str) == Some(tag) {
176 if match_data.is_some() {
177 return Err(format!("Lore returned multiple {tag} events"));
178 }
179 match_data = event.get("data").cloned();
180 }
181 }
182 match_data.ok_or_else(|| format!("Lore returned no {tag} event"))
183}
184
185fn select_http_token(
186 events: &str,
187 repository: &str,
188 user: &str,
189 origin: &str,
190 now_ms: u64,
191) -> Result<Option<String>, NapError> {
192 let url = crate::provider::http::validate_origin(origin)
193 .map_err(|e| NapError::Other(e.to_string()))?;
194 let host = url.host_str().unwrap();
195 let mut selected = None;
196 for line in events.lines().filter(|line| !line.trim().is_empty()) {
197 let event: serde_json::Value = serde_json::from_str(line)
199 .map_err(|_| NapError::VcsError("invalid Lore identity response".into()))?;
200 if event["tagName"] != "authIdentity" {
201 continue;
202 }
203 let data = &event["data"];
204 if data["resource"].as_str() != Some(repository)
205 || data["userId"].as_str() != Some(user)
206 || data["expires"].as_u64().unwrap_or(0) <= now_ms
207 {
208 continue;
209 }
210 let authorized = data["authorizedDomains"]
211 .as_str()
212 .unwrap_or("")
213 .split(',')
214 .any(|domain| {
215 let domain = domain.trim().to_ascii_lowercase();
216 !domain.is_empty()
217 && (host.eq_ignore_ascii_case(&domain)
218 || host.to_ascii_lowercase().ends_with(&format!(".{domain}")))
219 });
220 if !authorized {
221 continue;
222 }
223 if let Some(token) = data["token"].as_str().filter(|s| !s.is_empty()) {
224 if selected
225 .as_deref()
226 .is_some_and(|previous| previous != token)
227 {
228 return Err(NapError::VcsError(
229 "ambiguous Lore repository credentials; run nap auth login".into(),
230 ));
231 }
232 selected = Some(token.to_string());
233 }
234 }
235 Ok(selected)
236}
237
238fn event_string(data: &serde_json::Value, field: &str) -> Result<String, String> {
239 data.get(field)
240 .and_then(serde_json::Value::as_str)
241 .filter(|value| !value.is_empty())
242 .map(str::to_owned)
243 .ok_or_else(|| format!("Lore {field} is missing or is not a string"))
244}
245
246fn validate_lower_hex(value: &str, bytes: usize, label: &str) -> Result<(), String> {
247 if value.len() != bytes * 2 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
248 return Err(format!("Lore returned an invalid {label}"));
249 }
250 Ok(())
251}
252
253fn hydrate_lore_file(
254 repo_path: &Path,
255 args: impl IntoIterator<Item = String>,
256 prefix: &str,
257) -> Result<Vec<u8>, NapError> {
258 let temp_dir = tempfile::Builder::new()
261 .prefix(&format!("nap-{prefix}-"))
262 .tempdir()
263 .map_err(|e| NapError::VcsError(format!("failed to create private temp directory: {e}")))?;
264 let output_path = temp_dir.path().join("content");
265 let output = output_path.to_string_lossy().into_owned();
266 let mut command_args: Vec<String> = args.into_iter().collect();
267 command_args.extend([
268 "--output".to_string(),
269 output,
270 "--non-interactive".to_string(),
271 ]);
272 LoreProcessRunner::run(command_args, Some(repo_path))?;
273 std::fs::read(&output_path).map_err(|e| {
274 NapError::VcsError(format!(
275 "failed to read Lore output {}: {e}",
276 output_path.display()
277 ))
278 })
279}
280
281fn parse_metadata_output(stdout: &str) -> Result<BTreeMap<String, String>, String> {
282 if let Ok(value) = serde_json::from_str::<serde_json::Value>(stdout) {
283 let mut metadata = BTreeMap::new();
284 if let serde_json::Value::Object(map) = value {
285 for (key, value) in map {
286 let rendered = match value {
287 serde_json::Value::String(s) => s,
288 serde_json::Value::Bool(b) => b.to_string(),
289 serde_json::Value::Number(n) => n.to_string(),
290 serde_json::Value::Null => continue,
291 other => serde_json::to_string(&other).map_err(|e| e.to_string())?,
292 };
293 metadata.insert(key, rendered);
294 }
295 }
296 return Ok(metadata);
297 }
298
299 let mut metadata = BTreeMap::new();
300 for line in stdout.lines() {
301 let trimmed = line.trim();
302 if trimmed.is_empty() {
303 continue;
304 }
305 if let Some((key, value)) = trimmed.split_once('=').or_else(|| trimmed.split_once(':')) {
306 let key = key.trim();
307 if !key.is_empty() {
308 metadata.insert(key.to_string(), value.trim().to_string());
309 }
310 }
311 }
312 Ok(metadata)
313}
314
315#[derive(Debug, Clone)]
328pub struct LoreBackend {
329 remote_url: String,
331 workspace_id: String,
333}
334
335impl LoreBackend {
336 pub fn from_nap_home(nap_home: &Path) -> Self {
339 if std::env::var("NAP_LORE_URL_BASE").is_ok() || std::env::var("NAP_WORKSPACE_ID").is_ok() {
340 return Self::from_env();
341 }
342 let workspace_id = std::fs::read_to_string(nap_home.join("provider.toml"))
343 .ok()
344 .and_then(|content| toml::from_str::<ProviderConfigToml>(&content).ok())
345 .and_then(|config| config.workspace_id)
346 .unwrap_or_else(|| "default".to_string());
347 Self::from_provider(&Self::configured_server_url(nap_home), &workspace_id)
348 }
349 pub fn configured_server_url(nap_home: &Path) -> String {
353 if let Ok(url) = std::env::var("NAP_LORE_URL_BASE") {
354 return url;
355 }
356 let config_path = nap_home.join("provider.toml");
357 if let Ok(content) = std::fs::read_to_string(config_path)
358 && let Ok(config) = toml::from_str::<ProviderConfigToml>(&content)
359 {
360 match config.provider_type.as_str() {
361 "remote" => {
362 if let Some(url) = config.remote_url {
363 return url;
364 }
365 }
366 "portals-cloud" => return PORTALS_CLOUD_URL.to_string(),
367 "local" => return "lore://localhost:41337".to_string(),
368 _ => {}
369 }
370 }
371 "lore://localhost:41337".to_string()
372 }
373 pub fn new(remote_url: &str, workspace_id: &str) -> Self {
378 Self {
379 remote_url: remote_url.to_string(),
380 workspace_id: workspace_id.to_string(),
381 }
382 }
383
384 pub fn remote_url(&self) -> &str {
385 &self.remote_url
386 }
387
388 pub fn clone_repo(url: &str, dest: &Path) -> Result<(), NapError> {
394 LoreProcessRunner::run(
395 [
396 "clone",
397 url,
398 dest.to_str().unwrap_or("."),
399 "--non-interactive",
400 ],
401 None,
402 )?;
403 Ok(())
404 }
405
406 pub fn clone_repo_with_root_files(
410 url: &str,
411 dest: &Path,
412 root_files: &[String],
413 ) -> Result<(), NapError> {
414 let mut args = vec![
415 "clone".to_string(),
416 url.to_string(),
417 dest.to_string_lossy().to_string(),
418 "--non-interactive".to_string(),
419 ];
420 for root_file in root_files {
421 args.push("--root-file".to_string());
422 args.push(root_file.clone());
423 }
424 LoreProcessRunner::run(args.iter().map(String::as_str), None)?;
425 Ok(())
426 }
427
428 pub fn sync_root_files(dest: &Path, root_files: &[String]) -> Result<(), NapError> {
430 let mut args = vec!["revision".to_string(), "sync".to_string()];
431 for root_file in root_files {
432 args.push("--root-file".to_string());
433 args.push(root_file.clone());
434 }
435 LoreProcessRunner::run(args.iter().map(String::as_str), Some(dest))?;
436 Ok(())
437 }
438
439 pub fn from_env() -> Self {
452 if let Ok(nap_dir) = std::env::var("NAP_DIR") {
454 let manager = crate::server::manager::ServerManager::new(Path::new(&nap_dir));
455 let _ = tokio::runtime::Handle::try_current().map(|handle| {
456 handle.block_on(async {
457 let _ = manager.ensure_running().await;
458 });
459 });
460 }
461
462 let url_from_env = std::env::var("NAP_LORE_URL_BASE").ok();
464 let workspace_from_env = std::env::var("NAP_WORKSPACE_ID").ok();
465
466 if url_from_env.is_some() || workspace_from_env.is_some() {
467 let base = url_from_env.unwrap_or_else(|| "lore://localhost:41337".to_string());
468 let workspace_id = workspace_from_env.unwrap_or_else(|| "default".to_string());
469 tracing::debug!(
470 url_base = %base,
471 workspace_id = %workspace_id,
472 "LoreBackend::from_env using environment variables (override)"
473 );
474 return Self {
475 remote_url: base,
476 workspace_id,
477 };
478 }
479
480 if let Ok(base_dir_str) = std::env::var("NAP_INIT_BASE_DIR") {
484 let base_path = PathBuf::from(&base_dir_str);
485 let provider_config_path = base_path.join("provider.toml");
486 if provider_config_path.exists()
487 && let Ok(config_content) = std::fs::read_to_string(&provider_config_path)
488 && let Ok(config) = toml::from_str::<ProviderConfigToml>(&config_content)
489 {
490 match config.provider_type.as_str() {
491 "local" => {
492 tracing::debug!(
493 url_base = "lore://localhost:41337",
494 workspace_id = "default",
495 "LoreBackend::from_env using local provider from NAP_INIT_BASE_DIR"
496 );
497 return Self {
498 remote_url: "lore://localhost:41337".to_string(),
499 workspace_id: "default".to_string(),
500 };
501 }
502 "remote" => {
503 if let (Some(url), Some(workspace)) =
504 (config.remote_url, config.workspace_id)
505 {
506 tracing::debug!(
507 url_base = %url,
508 workspace_id = %workspace,
509 "LoreBackend::from_env using remote provider from NAP_INIT_BASE_DIR"
510 );
511 return Self {
512 remote_url: url,
513 workspace_id: workspace,
514 };
515 }
516 }
517 "portals-cloud" => {
518 let workspace_id =
519 config.workspace_id.unwrap_or_else(|| "default".to_string());
520 tracing::debug!(
521 url_base = %PORTALS_CLOUD_URL,
522 workspace_id = %workspace_id,
523 "LoreBackend::from_env using portals-cloud provider from NAP_INIT_BASE_DIR"
524 );
525 return Self {
526 remote_url: PORTALS_CLOUD_URL.to_string(),
527 workspace_id,
528 };
529 }
530 _ => {}
531 }
532 }
533 }
534
535 let nap_dir = if let Ok(nap_dir_str) = std::env::var("NAP_DIR") {
537 let path = PathBuf::from(&nap_dir_str);
539 if let Some(s) = path.to_str() {
540 if let Some(stripped) = s.strip_prefix('~') {
541 let home = std::env::var("HOME")
542 .or_else(|_| std::env::var("USERPROFILE"))
543 .unwrap_or_else(|_| ".".to_string());
544 PathBuf::from(home).join(stripped.trim_start_matches('/'))
545 } else {
546 path
547 }
548 } else {
549 path
550 }
551 } else {
552 let home = std::env::var("HOME")
554 .or_else(|_| std::env::var("USERPROFILE"))
555 .unwrap_or_else(|_| ".".to_string());
556 PathBuf::from(home).join(".nap")
557 };
558
559 let provider_config_path = nap_dir.join("provider.toml");
560 if provider_config_path.exists()
561 && let Ok(config_content) = std::fs::read_to_string(&provider_config_path)
562 && let Ok(config) = toml::from_str::<ProviderConfigToml>(&config_content)
563 {
564 match config.provider_type.as_str() {
565 "local" => {
566 tracing::debug!(
568 url_base = "lore://localhost:41337",
569 workspace_id = "default",
570 "LoreBackend::from_env using local provider configuration"
571 );
572 return Self {
573 remote_url: "lore://localhost:41337".to_string(),
574 workspace_id: "default".to_string(),
575 };
576 }
577 "remote" => {
578 if let (Some(url), Some(workspace)) = (config.remote_url, config.workspace_id) {
580 tracing::debug!(
581 url_base = %url,
582 workspace_id = %workspace,
583 "LoreBackend::from_env using remote provider configuration"
584 );
585 return Self {
586 remote_url: url,
587 workspace_id: workspace,
588 };
589 }
590 }
591 "portals-cloud" => {
592 let workspace_id = config.workspace_id.unwrap_or_else(|| "default".to_string());
594 tracing::debug!(
595 url_base = %PORTALS_CLOUD_URL,
596 workspace_id = %workspace_id,
597 "LoreBackend::from_env using portals-cloud provider configuration"
598 );
599 return Self {
600 remote_url: PORTALS_CLOUD_URL.to_string(),
601 workspace_id,
602 };
603 }
604 _ => {
605 tracing::debug!(
606 provider_type = %config.provider_type,
607 "Unknown provider type, falling back to defaults"
608 );
609 }
610 }
611 }
612
613 let base = "lore://localhost:41337".to_string();
615 let workspace_id = "default".to_string();
616 tracing::debug!(
617 url_base = %base,
618 workspace_id = %workspace_id,
619 "LoreBackend::from_env using defaults"
620 );
621 Self {
622 remote_url: base,
623 workspace_id,
624 }
625 }
626
627 pub fn from_provider(url_base: &str, workspace_id: &str) -> Self {
631 tracing::debug!(
632 url_base = %url_base,
633 workspace_id = %workspace_id,
634 "Creating LoreBackend from provider configuration"
635 );
636
637 Self {
638 remote_url: url_base.to_string(),
639 workspace_id: workspace_id.to_string(),
640 }
641 }
642
643 fn repo_url(&self, repo_id: &str) -> String {
645 format!("{}/{}", self.remote_url.trim_end_matches('/'), repo_id)
646 }
647}
648
649impl VcsBackend for LoreBackend {
650 fn remote_url_base(&self) -> Result<String, NapError> {
652 Ok(self.remote_url.clone())
653 }
654
655 fn init(&self, path: &Path) -> Result<(), NapError> {
657 let raw_id = path
666 .file_name()
667 .and_then(|n| n.to_str())
668 .unwrap_or("nap-repo");
669 let repo_id = {
678 let from_manifest = path
679 .join("repository.yaml")
680 .exists()
681 .then(|| {
682 std::fs::read_to_string(path.join("repository.yaml"))
683 .ok()
684 .and_then(|c| {
685 serde_yaml::from_str::<serde_yaml::Value>(&c)
686 .ok()
687 .and_then(|v| {
688 v.get("id").and_then(|id| id.as_str()).and_then(|id_str| {
689 id_str.strip_prefix("nap://").and_then(|rest| {
691 rest.split('/').next().map(|s| s.to_string())
692 })
693 })
694 })
695 })
696 })
697 .flatten()
698 .filter(|s| {
699 !s.is_empty()
700 && s.chars()
701 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
702 });
703 from_manifest.unwrap_or_else(|| {
704 let sanitized = raw_id.trim_start_matches(['.', '_']);
705 if sanitized.is_empty()
706 || !sanitized
707 .chars()
708 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
709 {
710 "nap-repo".to_string()
711 } else {
712 sanitized.to_string()
713 }
714 })
715 };
716
717 let url = self.repo_url(&repo_id);
718 let path_str = path.to_str().unwrap_or(".");
719
720 let server_path = path
722 .parent()
723 .unwrap_or(path)
724 .join(".lore-server")
725 .join(repo_id);
726
727 LoreProcessRunner::run(
729 [
730 "repository",
731 "create",
732 &url,
733 "--id",
734 &self.workspace_id,
735 "--repository",
736 server_path.to_str().unwrap_or("."),
737 "--non-interactive",
738 ],
739 None,
740 )
741 .map_err(|e| {
742 NapError::VcsError(format!("failed to create lore repository '{}': {}", url, e))
743 })?;
744
745 LoreProcessRunner::run(["clone", &url, path_str, "--non-interactive"], None).map_err(
747 |e| {
748 NapError::VcsError(format!(
749 "failed to clone lore repository to {:?}: {}",
750 path, e
751 ))
752 },
753 )?;
754
755 Ok(())
756 }
757
758 fn commit(&self, path: &Path, message: &str, author: &str) -> Result<String, NapError> {
760 LoreProcessRunner::run(["stage", "--scan", ".", "--non-interactive"], Some(path))?;
763
764 let stdout = LoreProcessRunner::run(
766 [
767 "revision",
768 "commit",
769 message,
770 "--identity",
771 author,
772 "--non-interactive",
773 ],
774 Some(path),
775 )?;
776
777 let signature = stdout
780 .lines()
781 .find_map(|line| {
782 line.strip_prefix("Signature :")
783 .or_else(|| line.strip_prefix("Signature:"))
784 })
785 .map(|s| s.trim().to_string())
786 .unwrap_or_else(|| {
787 stdout
789 .lines()
790 .next()
791 .unwrap_or(&stdout)
792 .trim()
793 .strip_prefix("Created revision ")
794 .and_then(|s| s.split_whitespace().next())
795 .map(|s| s.to_string())
796 .unwrap_or_else(|| stdout.trim().to_string())
797 });
798
799 Ok(signature)
800 }
801
802 fn read_file_at_ref(
804 &self,
805 repo_path: &Path,
806 file_path: &str,
807 reference: Option<&str>,
808 ) -> Result<String, NapError> {
809 let bytes = self.read_file_bytes_at_ref(repo_path, file_path, reference)?;
810 String::from_utf8(bytes).map_err(|e| {
811 NapError::VcsError(format!(
812 "{} is not valid UTF-8; use read_file_bytes_at_ref for binary content: {e}",
813 file_path
814 ))
815 })
816 }
817
818 fn read_file_bytes_at_ref(
819 &self,
820 repo_path: &Path,
821 file_path: &str,
822 reference: Option<&str>,
823 ) -> Result<Vec<u8>, NapError> {
824 let Some(reference) = reference else {
825 let full_path = repo_path.join(file_path);
826 return std::fs::read(&full_path).map_err(|e| {
827 NapError::VcsError(format!("failed to read {}: {e}", full_path.display()))
828 });
829 };
830
831 hydrate_lore_file(
832 repo_path,
833 [
834 "file".to_string(),
835 "write".to_string(),
836 "--path".to_string(),
837 file_path.to_string(),
838 "--revision".to_string(),
839 reference.to_string(),
840 ],
841 "file-at-ref",
842 )
843 }
844
845 fn repository_descriptor(&self, repo_path: &Path) -> Result<VcsRepositoryDescriptor, NapError> {
846 let stdout = LoreProcessRunner::run(
847 ["repository", "info", "--json", "--non-interactive"],
848 Some(repo_path),
849 )?;
850 let data = parse_lore_event_data(&stdout, "repositoryData").map_err(|e| {
851 NapError::VcsError(format!("failed to parse Lore repository info: {e}"))
852 })?;
853 let id = event_string(&data, "id").map_err(|e| {
854 NapError::VcsError(format!("failed to parse Lore repository info: {e}"))
855 })?;
856 validate_lower_hex(&id, 16, "repository ID").map_err(|e| {
857 NapError::VcsError(format!("failed to parse Lore repository info: {e}"))
858 })?;
859 let remote_url = data
860 .get("remoteUrl")
861 .and_then(serde_json::Value::as_str)
862 .unwrap_or_default()
863 .to_string();
864 Ok(VcsRepositoryDescriptor { id, remote_url })
865 }
866
867 fn http_bearer_token(
868 &self,
869 repo_path: &Path,
870 repository_id: &str,
871 http_origin: &str,
872 ) -> Result<Option<String>, NapError> {
873 let identity = LoreProcessRunner::run(
876 ["auth", "info", "--json", "--non-interactive"],
877 Some(repo_path),
878 )?;
879 let user = parse_lore_event_data(&identity, "authUserInfo")
880 .and_then(|data| event_string(&data, "id"))
881 .map_err(|_| {
882 NapError::VcsError("No active Lore identity; run nap auth login".into())
883 })?;
884 let tokens = LoreProcessRunner::run(
885 [
886 "auth",
887 "list",
888 "--with-token",
889 "--json",
890 "--non-interactive",
891 ],
892 Some(repo_path),
893 )
894 .map_err(|_| {
895 NapError::VcsError(
896 "Could not read Lore repository credentials; run nap auth login".into(),
897 )
898 })?;
899 select_http_token(
900 &tokens,
901 repository_id,
902 &user,
903 http_origin,
904 chrono::Utc::now().timestamp_millis().max(0) as u64,
905 )
906 }
907
908 fn file_content_address_at_ref(
909 &self,
910 repo_path: &Path,
911 file_path: &str,
912 reference: &str,
913 ) -> Result<VcsContentAddress, NapError> {
914 let stdout = LoreProcessRunner::run(
915 [
916 "file",
917 "info",
918 file_path,
919 "--revision",
920 reference,
921 "--json",
922 "--non-interactive",
923 ],
924 Some(repo_path),
925 )?;
926 let data = parse_lore_event_data(&stdout, "fileInfo")
927 .map_err(|e| NapError::VcsError(format!("failed to parse Lore file info: {e}")))?;
928 if data.get("isFile").and_then(serde_json::Value::as_bool) != Some(true) {
929 return Err(NapError::VcsError(format!(
930 "representation path '{file_path}' is not a file at revision '{reference}'"
931 )));
932 }
933 let hash = event_string(&data, "hash")
934 .map_err(|e| NapError::VcsError(format!("failed to parse Lore file info: {e}")))?;
935 let context = event_string(&data, "context")
936 .map_err(|e| NapError::VcsError(format!("failed to parse Lore file info: {e}")))?;
937 validate_lower_hex(&hash, 32, "file hash")
938 .and_then(|_| validate_lower_hex(&context, 16, "file context"))
939 .map_err(|e| NapError::VcsError(format!("failed to parse Lore file info: {e}")))?;
940 Ok(VcsContentAddress { hash, context })
941 }
942
943 fn file_metadata_at_ref(
945 &self,
946 repo_path: &Path,
947 file_path: &str,
948 reference: &str,
949 ) -> Result<Option<BTreeMap<String, String>>, NapError> {
950 let stdout = LoreProcessRunner::run(
951 [
952 "file",
953 "metadata",
954 "get",
955 file_path,
956 "--revision",
957 reference,
958 "--non-interactive",
959 ],
960 Some(repo_path),
961 )?;
962
963 if stdout.trim().is_empty() || stdout.trim() == "null" {
964 return Ok(None);
965 }
966
967 parse_metadata_output(&stdout)
968 .map(Some)
969 .map_err(|e| NapError::VcsError(format!("failed to parse lore file metadata: {e}")))
970 }
971
972 fn read_provenance_blob(&self, repo_path: &Path, address: &str) -> Result<String, NapError> {
973 let bytes = hydrate_lore_file(
974 repo_path,
975 [
976 "file".to_string(),
977 "write".to_string(),
978 "--address".to_string(),
979 address.to_string(),
980 ],
981 "provenance-blob",
982 )?;
983 String::from_utf8(bytes).map_err(|e| {
984 NapError::VcsError(format!(
985 "hydrated provenance blob {address} is not valid UTF-8: {e}"
986 ))
987 })
988 }
989
990 fn log(
992 &self,
993 path: &Path,
994 _file: Option<&str>,
995 limit: usize,
996 ) -> Result<Vec<CommitInfo>, NapError> {
997 let limit_str = limit.to_string();
998 let args = vec!["history", &limit_str, "--non-interactive"];
999
1000 let stdout = LoreProcessRunner::run(&args, Some(path))?;
1001
1002 if stdout.trim().is_empty() {
1003 return Ok(Vec::new());
1004 }
1005
1006 let mut commits = Vec::new();
1015 let mut current_signature = String::new();
1016 let mut current_author = String::new();
1017 let mut current_message = String::new();
1018 let mut current_timestamp = String::new();
1019 let mut current_parent: Option<String> = None;
1020 let mut in_message = false;
1021
1022 for line in stdout.lines() {
1023 let trimmed = line.trim();
1024 if trimmed.starts_with("Signature :") || trimmed.starts_with("Signature:") {
1025 if !current_signature.is_empty() {
1027 commits.push(CommitInfo {
1028 id: std::mem::take(&mut current_signature),
1029 parent: current_parent.take(),
1030 author: std::mem::take(&mut current_author),
1031 message: std::mem::take(&mut current_message),
1032 timestamp: std::mem::take(&mut current_timestamp),
1033 });
1034 }
1035 current_signature = trimmed
1036 .strip_prefix("Signature :")
1037 .or_else(|| trimmed.strip_prefix("Signature:"))
1038 .unwrap_or("")
1039 .trim()
1040 .to_string();
1041 in_message = false;
1042 } else if trimmed.starts_with("Date :") || trimmed.starts_with("Date:") {
1043 current_timestamp = trimmed
1044 .split_once(':')
1045 .map(|(_, v)| v.trim().to_string())
1046 .unwrap_or_default();
1047 in_message = true;
1048 } else if trimmed.starts_with("Creator :") || trimmed.starts_with("Creator:") {
1049 current_author = trimmed
1050 .split_once(':')
1051 .map(|(_, v)| v.trim().to_string())
1052 .unwrap_or_default();
1053 in_message = false;
1054 } else if trimmed.starts_with("Revision :")
1055 || trimmed.starts_with("Revision:")
1056 || trimmed.starts_with("Branch :")
1057 || trimmed.starts_with("Branch:")
1058 || trimmed.starts_with("Committer :")
1059 || trimmed.starts_with("Committer:")
1060 {
1061 in_message = false;
1062 } else if in_message {
1063 if trimmed.is_empty() || trimmed == "Commit succeeded" {
1064 in_message = false;
1065 } else {
1066 if !current_message.is_empty() {
1067 current_message.push('\n');
1068 }
1069 current_message.push_str(trimmed);
1070 }
1071 }
1072 }
1073 if !current_signature.is_empty() {
1075 commits.push(CommitInfo {
1076 id: current_signature,
1077 parent: current_parent,
1078 author: current_author,
1079 message: current_message,
1080 timestamp: current_timestamp,
1081 });
1082 }
1083
1084 Ok(commits)
1085 }
1086
1087 fn create_branch(&self, path: &Path, name: &str) -> Result<(), NapError> {
1089 LoreProcessRunner::run(["branch", "create", name, "--non-interactive"], Some(path))?;
1090 Ok(())
1091 }
1092
1093 fn switch_branch(&self, path: &Path, name: &str) -> Result<(), NapError> {
1094 LoreProcessRunner::run(["branch", "switch", name, "--non-interactive"], Some(path))?;
1095 Ok(())
1096 }
1097
1098 fn current_branch(&self, path: &Path) -> Result<String, NapError> {
1099 let stdout = LoreProcessRunner::run(["branch", "show", "--non-interactive"], Some(path))?;
1100 Ok(stdout.trim().to_string())
1101 }
1102
1103 fn list_branches(&self, path: &Path) -> Result<Vec<String>, NapError> {
1104 let stdout = LoreProcessRunner::run(["branch", "list", "--non-interactive"], Some(path))?;
1105 if stdout.is_empty() {
1106 return Ok(Vec::new());
1107 }
1108 let mut branches = Vec::new();
1115 let mut in_local = false;
1116 for line in stdout.lines() {
1117 let trimmed = line.trim();
1118 if trimmed.starts_with("Local branches") {
1119 in_local = true;
1120 continue;
1121 }
1122 if trimmed.starts_with("Remote branches") {
1123 in_local = false;
1124 continue;
1125 }
1126 if in_local && !trimmed.is_empty() {
1127 let name = trimmed.strip_prefix("* ").unwrap_or(trimmed);
1129 branches.push(name.to_string());
1130 }
1131 }
1132 Ok(branches)
1133 }
1134
1135 fn head_hash(&self, path: &Path) -> Result<String, NapError> {
1137 let stdout = LoreProcessRunner::run(["history", "1", "--non-interactive"], Some(path))?;
1138
1139 if stdout.trim().is_empty() {
1140 return Err(NapError::VcsError(
1141 "no commits in lore workspace".to_string(),
1142 ));
1143 }
1144
1145 stdout
1147 .lines()
1148 .find_map(|line| {
1149 line.trim()
1150 .strip_prefix("Signature :")
1151 .or_else(|| line.trim().strip_prefix("Signature:"))
1152 })
1153 .map(|s| s.trim().to_string())
1154 .ok_or_else(|| {
1155 NapError::VcsError(format!(
1156 "failed to parse signature from lore history: {stdout}"
1157 ))
1158 })
1159 }
1160
1161 fn revert(&self, path: &Path, commit_hash: &str) -> Result<String, NapError> {
1162 let stdout = LoreProcessRunner::run(
1163 ["revision", "revert", commit_hash, "--non-interactive"],
1164 Some(path),
1165 )?;
1166 let signature = stdout
1168 .trim()
1169 .strip_prefix("Created revert revision ")
1170 .unwrap_or(stdout.trim());
1171 Ok(signature.to_string())
1172 }
1173
1174 fn resolve_branch_head(&self, path: &Path, branch: &str) -> Result<String, NapError> {
1175 let stdout = LoreProcessRunner::run(
1176 ["history", "1", "--branch", branch, "--non-interactive"],
1177 Some(path),
1178 )?;
1179
1180 if stdout.trim().is_empty() {
1181 return Err(NapError::VcsError(format!(
1182 "no commits found on branch '{branch}'"
1183 )));
1184 }
1185
1186 stdout
1188 .lines()
1189 .find_map(|line| {
1190 line.trim()
1191 .strip_prefix("Signature :")
1192 .or_else(|| line.trim().strip_prefix("Signature:"))
1193 })
1194 .map(|s| s.trim().to_string())
1195 .ok_or_else(|| {
1196 NapError::VcsError(format!(
1197 "failed to parse signature from lore history on branch '{branch}': {stdout}"
1198 ))
1199 })
1200 }
1201
1202 fn add_remote(&self, path: &Path, name: &str, url: &str) -> Result<(), NapError> {
1206 let remotes_path = path.join(".lore").join("remotes.toml");
1207 let mut map: std::collections::BTreeMap<String, String> = if remotes_path.exists() {
1208 let content = std::fs::read_to_string(&remotes_path).unwrap_or_default();
1209 toml::from_str(&content).unwrap_or_default()
1210 } else {
1211 std::collections::BTreeMap::new()
1212 };
1213 map.insert(name.to_string(), url.to_string());
1214 if let Some(parent) = remotes_path.parent() {
1215 std::fs::create_dir_all(parent).map_err(|e| NapError::VcsError(e.to_string()))?;
1216 }
1217 let content = toml::to_string(&map).map_err(|e| NapError::VcsError(e.to_string()))?;
1218 std::fs::write(&remotes_path, content).map_err(|e| NapError::VcsError(e.to_string()))?;
1219 Ok(())
1220 }
1221
1222 fn remove_remote(&self, path: &Path, name: &str) -> Result<(), NapError> {
1223 let remotes_path = path.join(".lore").join("remotes.toml");
1224 if !remotes_path.exists() {
1225 return Ok(());
1226 }
1227 let content = std::fs::read_to_string(&remotes_path).unwrap_or_default();
1228 let mut map: std::collections::BTreeMap<String, String> =
1229 toml::from_str(&content).unwrap_or_default();
1230 map.remove(name);
1231 let new_content = toml::to_string(&map).map_err(|e| NapError::VcsError(e.to_string()))?;
1232 std::fs::write(&remotes_path, new_content)
1233 .map_err(|e| NapError::VcsError(e.to_string()))?;
1234 Ok(())
1235 }
1236
1237 fn list_remotes(&self, path: &Path) -> Result<Vec<(String, String)>, NapError> {
1238 let remotes_path = path.join(".lore").join("remotes.toml");
1239 if !remotes_path.exists() {
1240 return Ok(Vec::new());
1241 }
1242 let content = std::fs::read_to_string(&remotes_path).unwrap_or_default();
1243 let map: std::collections::BTreeMap<String, String> =
1244 toml::from_str(&content).unwrap_or_default();
1245 Ok(map.into_iter().collect())
1246 }
1247
1248 fn push(
1250 &self,
1251 path: &Path,
1252 _remote: Option<&str>,
1253 branch: Option<&str>,
1254 ) -> Result<(), NapError> {
1255 let branch_name = match branch {
1258 Some(b) => b.to_string(),
1259 None => self
1260 .current_branch(path)
1261 .unwrap_or_else(|_| "main".to_string()),
1262 };
1263
1264 let args = vec![
1266 "branch",
1267 "push",
1268 &branch_name,
1269 "--fast-forward-merge",
1270 "--non-interactive",
1271 ];
1272 LoreProcessRunner::run(&args, Some(path))?;
1273
1274 Ok(())
1275 }
1276
1277 fn pull(
1278 &self,
1279 path: &Path,
1280 _remote: Option<&str>,
1281 _branch: Option<&str>,
1282 ) -> Result<(), NapError> {
1283 let args = vec!["sync", "--non-interactive", "--reset"];
1285 LoreProcessRunner::run(&args, Some(path))?;
1286
1287 Ok(())
1288 }
1289}
1290
1291#[cfg(test)]
1296mod structured_output_tests {
1297 use super::*;
1298
1299 #[test]
1300 fn http_credentials_are_scoped_to_identity_repository_domain_and_expiry() {
1301 let token = |repo: &str, user: &str, domain: &str, expires: u64| {
1302 serde_json::json!({
1303 "tagName": "authIdentity", "data": { "resource": repo, "userId": user,
1304 "authorizedDomains": domain, "expires": expires, "token": "test-secret" }
1305 })
1306 .to_string()
1307 };
1308 let origin = "https://lore.portals.works";
1309 assert_eq!(
1310 select_http_token(
1311 &token("repo", "alice", "portals.works", 2000),
1312 "repo",
1313 "alice",
1314 origin,
1315 1000
1316 )
1317 .unwrap()
1318 .as_deref(),
1319 Some("test-secret")
1320 );
1321 for event in [
1322 token("", "alice", "portals.works", 2000),
1323 token("other", "alice", "portals.works", 2000),
1324 token("repo", "bob", "portals.works", 2000),
1325 token("repo", "alice", "", 2000),
1326 token("repo", "alice", "other.test", 2000),
1327 token("repo", "alice", "portals.works", 500),
1328 ] {
1329 assert!(
1330 select_http_token(&event, "repo", "alice", origin, 1000)
1331 .unwrap()
1332 .is_none()
1333 );
1334 }
1335 assert!(
1336 select_http_token(
1337 &token("repo", "alice", "portals.works", 2000),
1338 "repo",
1339 "alice",
1340 "https://evilportals.works",
1341 1000
1342 )
1343 .unwrap()
1344 .is_none()
1345 );
1346 }
1347
1348 #[test]
1349 fn parses_repository_and_file_events_independently() {
1350 let repository = concat!(
1351 "{\"tagName\":\"repositoryData\",\"data\":{",
1352 "\"id\":\"0123456789abcdef0123456789abcdef\",",
1353 "\"remoteUrl\":\"lore://localhost:41337/repo\"}}\n",
1354 "{\"tagName\":\"complete\",\"data\":{}}"
1355 );
1356 let file = concat!(
1357 "{\"tagName\":\"fileInfo\",\"data\":{",
1358 "\"hash\":\"9753abf79e5aef60bd95ab76c1e5a14d01239beb37ff9897b6af8e040eb2413a\",",
1359 "\"context\":\"fedcba9876543210fedcba9876543210\",\"isFile\":true}}"
1360 );
1361 let repository_data = parse_lore_event_data(repository, "repositoryData").unwrap();
1362 let file_data = parse_lore_event_data(file, "fileInfo").unwrap();
1363 assert_eq!(
1364 event_string(&repository_data, "id").unwrap(),
1365 "0123456789abcdef0123456789abcdef"
1366 );
1367 assert_eq!(
1368 event_string(&file_data, "context").unwrap(),
1369 "fedcba9876543210fedcba9876543210"
1370 );
1371 }
1372
1373 #[test]
1374 fn rejects_duplicate_or_malformed_events() {
1375 let duplicate =
1376 "{\"tagName\":\"fileInfo\",\"data\":{}}\n{\"tagName\":\"fileInfo\",\"data\":{}}";
1377 assert!(parse_lore_event_data(duplicate, "fileInfo").is_err());
1378 assert!(parse_lore_event_data("not-json", "fileInfo").is_err());
1379 assert!(validate_lower_hex("abc", 16, "context").is_err());
1380 }
1381
1382 #[test]
1383 fn working_tree_binary_reads_are_lossless() {
1384 let temp = tempfile::TempDir::new().unwrap();
1385 let bytes = [0_u8, 0xff, 0x42];
1386 std::fs::write(temp.path().join("asset.bin"), bytes).unwrap();
1387 let backend = LoreBackend::from_env();
1388 assert_eq!(
1389 backend
1390 .read_file_bytes_at_ref(temp.path(), "asset.bin", None)
1391 .unwrap(),
1392 bytes
1393 );
1394 assert!(
1395 backend
1396 .read_file_at_ref(temp.path(), "asset.bin", None)
1397 .unwrap_err()
1398 .to_string()
1399 .contains("read_file_bytes_at_ref")
1400 );
1401 }
1402}
1403
1404#[cfg(all(test, feature = "lore-integration"))]
1405mod tests {
1406 use super::*;
1407
1408 #[test]
1411 fn test_binary_default() {
1412 assert_eq!(LoreProcessRunner::binary(), "lore");
1413 }
1414
1415 #[test]
1416 fn test_binary_from_env() {
1417 temp_env::with_var("NAPLORE_CLI", Some("/custom/lore"), || {
1418 assert_eq!(LoreProcessRunner::binary(), "/custom/lore");
1419 });
1420 }
1421
1422 #[test]
1423 fn test_run_captures_stdout() {
1424 temp_env::with_var("NAPLORE_CLI", Some("lore-nonexistent-binary-12345"), || {
1428 let result = LoreProcessRunner::run(["--version"], None);
1429 assert!(result.is_err());
1430 let err = result.unwrap_err().to_string();
1431 assert!(
1432 err.contains("lore-nonexistent-binary-12345"),
1433 "error: {}",
1434 err
1435 );
1436 });
1437 }
1438
1439 #[test]
1442 fn test_new_and_from_env() {
1443 let backend = LoreBackend::new("lore://myhost:8700", "test-workspace");
1444 assert_eq!(backend.remote_url, "lore://myhost:8700");
1445 assert_eq!(backend.workspace_id, "test-workspace");
1446
1447 temp_env::with_vars(
1448 vec![
1449 ("NAP_LORE_URL_BASE", Some("lore://custom:9999")),
1450 ("NAP_WORKSPACE_ID", Some("custom-ws")),
1451 ],
1452 || {
1453 let from_env = LoreBackend::from_env();
1454 assert_eq!(from_env.remote_url, "lore://custom:9999");
1455 assert_eq!(from_env.workspace_id, "custom-ws");
1456 },
1457 );
1458 }
1459
1460 #[test]
1461 fn test_from_env_default_without_env_vars() {
1462 let temp_dir = tempfile::TempDir::new().unwrap();
1464 let nap_dir_str = temp_dir.path().to_str().unwrap();
1465
1466 temp_env::with_vars(
1467 vec![
1468 ("NAP_LORE_URL_BASE", None::<&str>),
1469 ("NAP_WORKSPACE_ID", None::<&str>),
1470 ("NAP_DIR", Some(nap_dir_str)),
1471 ],
1472 || {
1473 let backend = LoreBackend::from_env();
1474 assert_eq!(backend.remote_url, "lore://localhost:41337");
1475 assert_eq!(backend.workspace_id, "default");
1476 },
1477 );
1478 }
1479
1480 #[test]
1481 fn test_from_env_env_var_override() {
1482 let temp_dir = tempfile::TempDir::new().unwrap();
1484 let nap_dir_str = temp_dir.path().to_str().unwrap();
1485
1486 temp_env::with_vars(
1487 vec![
1488 ("NAP_LORE_URL_BASE", Some("lore://override:1234")),
1489 ("NAP_WORKSPACE_ID", Some("override-ws")),
1490 ("NAP_DIR", Some(nap_dir_str)),
1491 ],
1492 || {
1493 let backend = LoreBackend::from_env();
1494 assert_eq!(backend.remote_url, "lore://override:1234");
1495 assert_eq!(backend.workspace_id, "override-ws");
1496 },
1497 );
1498 }
1499
1500 #[test]
1501 fn test_from_env_partial_env_override() {
1502 let temp_dir = tempfile::TempDir::new().unwrap();
1504 let nap_dir_str = temp_dir.path().to_str().unwrap();
1505
1506 temp_env::with_vars(
1507 vec![
1508 ("NAP_LORE_URL_BASE", Some("lore://partial:5678")),
1509 ("NAP_WORKSPACE_ID", None::<&str>),
1510 ("NAP_DIR", Some(nap_dir_str)),
1511 ],
1512 || {
1513 let backend = LoreBackend::from_env();
1514 assert_eq!(backend.remote_url, "lore://partial:5678");
1515 assert_eq!(backend.workspace_id, "default");
1516 },
1517 );
1518 }
1519
1520 #[test]
1521 fn test_from_env_provider_config() {
1522 let temp_dir = tempfile::TempDir::new().unwrap();
1524 let provider_config = temp_dir.path().join("provider.toml");
1525 std::fs::write(
1526 &provider_config,
1527 r#"
1528provider_type = "remote"
1529remote_url = "lore://provider:9999"
1530workspace_id = "provider-ws"
1531"#,
1532 )
1533 .unwrap();
1534
1535 let nap_dir_str = temp_dir.path().to_str().unwrap();
1536 temp_env::with_vars(
1537 vec![
1538 ("NAP_LORE_URL_BASE", None::<&str>),
1539 ("NAP_WORKSPACE_ID", None::<&str>),
1540 ("NAP_DIR", Some(nap_dir_str)),
1541 ],
1542 || {
1543 let backend = LoreBackend::from_env();
1544 assert_eq!(backend.remote_url, "lore://provider:9999");
1545 assert_eq!(backend.workspace_id, "provider-ws");
1546 },
1547 );
1548 }
1549
1550 #[test]
1551 fn test_from_env_nap_dir_with_tilde() {
1552 let _home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
1554 let temp_dir = tempfile::TempDir::new().unwrap();
1555 let nap_dir_str = temp_dir.path().to_str().unwrap();
1556
1557 temp_env::with_vars(
1558 vec![
1559 ("NAP_LORE_URL_BASE", None::<&str>),
1560 ("NAP_WORKSPACE_ID", None::<&str>),
1561 ("NAP_DIR", Some(nap_dir_str)),
1562 ],
1563 || {
1564 let backend = LoreBackend::from_env();
1565 assert_eq!(backend.remote_url, "lore://localhost:41337");
1567 assert_eq!(backend.workspace_id, "default");
1568 },
1569 );
1570 }
1571
1572 #[test]
1573 fn test_from_env_local_provider_config() {
1574 let temp_dir = tempfile::TempDir::new().unwrap();
1576 let provider_config = temp_dir.path().join("provider.toml");
1577 std::fs::write(
1578 &provider_config,
1579 r#"
1580provider_type = "local"
1581"#,
1582 )
1583 .unwrap();
1584
1585 let nap_dir_str = temp_dir.path().to_str().unwrap();
1586 temp_env::with_vars(
1587 vec![
1588 ("NAP_LORE_URL_BASE", None::<&str>),
1589 ("NAP_WORKSPACE_ID", None::<&str>),
1590 ("NAP_DIR", Some(nap_dir_str)),
1591 ],
1592 || {
1593 let backend = LoreBackend::from_env();
1594 assert_eq!(backend.remote_url, "lore://localhost:41337");
1595 assert_eq!(backend.workspace_id, "default");
1596 },
1597 );
1598 }
1599
1600 #[test]
1601 fn test_from_env_portals_cloud_provider_config() {
1602 let temp_dir = tempfile::TempDir::new().unwrap();
1604 let provider_config = temp_dir.path().join("provider.toml");
1605 std::fs::write(
1606 &provider_config,
1607 r#"
1608provider_type = "portals-cloud"
1609workspace_id = "cloud-ws"
1610"#,
1611 )
1612 .unwrap();
1613
1614 let nap_dir_str = temp_dir.path().to_str().unwrap();
1615 temp_env::with_vars(
1616 vec![
1617 ("NAP_LORE_URL_BASE", None::<&str>),
1618 ("NAP_WORKSPACE_ID", None::<&str>),
1619 ("NAP_DIR", Some(nap_dir_str)),
1620 ],
1621 || {
1622 let backend = LoreBackend::from_env();
1623 assert_eq!(backend.remote_url, PORTALS_CLOUD_URL);
1624 assert_eq!(backend.workspace_id, "cloud-ws");
1625 },
1626 );
1627 }
1628
1629 #[test]
1630 fn test_from_env_portals_cloud_default_workspace() {
1631 let temp_dir = tempfile::TempDir::new().unwrap();
1633 let provider_config = temp_dir.path().join("provider.toml");
1634 std::fs::write(
1635 &provider_config,
1636 r#"
1637provider_type = "portals-cloud"
1638"#,
1639 )
1640 .unwrap();
1641
1642 let nap_dir_str = temp_dir.path().to_str().unwrap();
1643 temp_env::with_vars(
1644 vec![
1645 ("NAP_LORE_URL_BASE", None::<&str>),
1646 ("NAP_WORKSPACE_ID", None::<&str>),
1647 ("NAP_DIR", Some(nap_dir_str)),
1648 ],
1649 || {
1650 let backend = LoreBackend::from_env();
1651 assert_eq!(backend.remote_url, PORTALS_CLOUD_URL);
1652 assert_eq!(backend.workspace_id, "default");
1653 },
1654 );
1655 }
1656
1657 #[test]
1658 fn test_from_env_unknown_provider_type() {
1659 let temp_dir = tempfile::TempDir::new().unwrap();
1661 let provider_config = temp_dir.path().join("provider.toml");
1662 std::fs::write(
1663 &provider_config,
1664 r#"
1665provider_type = "unknown-provider"
1666"#,
1667 )
1668 .unwrap();
1669
1670 let nap_dir_str = temp_dir.path().to_str().unwrap();
1671 temp_env::with_vars(
1672 vec![
1673 ("NAP_LORE_URL_BASE", None::<&str>),
1674 ("NAP_WORKSPACE_ID", None::<&str>),
1675 ("NAP_DIR", Some(nap_dir_str)),
1676 ],
1677 || {
1678 let backend = LoreBackend::from_env();
1679 assert_eq!(backend.remote_url, "lore://localhost:41337");
1680 assert_eq!(backend.workspace_id, "default");
1681 },
1682 );
1683 }
1684
1685 #[test]
1686 fn test_repo_url_joining() {
1687 let backend = LoreBackend::new("lore://localhost:8700", "ws");
1688 assert_eq!(backend.repo_url("my-repo"), "lore://localhost:8700/my-repo");
1689
1690 let backend2 = LoreBackend::new("lore://host:8700/", "ws");
1692 assert_eq!(backend2.repo_url("foo"), "lore://host:8700/foo");
1693 }
1694
1695 #[test]
1696 fn test_list_branches_empty_json() {
1697 }
1702
1703 #[test]
1704 fn test_commit_parses_signature_from_stdout() {
1705 let sample = "Created revision a1b2c3d4 (#42)";
1709 let signature = sample
1710 .strip_prefix("Created revision ")
1711 .and_then(|s| s.split_whitespace().next())
1712 .unwrap_or(sample);
1713 assert_eq!(signature, "a1b2c3d4");
1714 }
1715
1716 #[test]
1719 fn test_commit_info_from_lore_revision() {
1720 let info = CommitInfo::from_lore_revision(
1721 "sig123",
1722 Some("sig122"),
1723 "alice",
1724 "feat: add manifest",
1725 "2026-06-30T12:00:00Z",
1726 );
1727 assert_eq!(info.id, "sig123");
1728 assert_eq!(info.parent.as_deref(), Some("sig122"));
1729 assert_eq!(info.author, "alice");
1730 assert_eq!(info.message, "feat: add manifest");
1731 assert_eq!(info.timestamp, "2026-06-30T12:00:00Z");
1732 }
1733
1734 #[test]
1735 fn test_commit_info_default_timestamp() {
1736 let info = CommitInfo::from_lore_revision("sig", None, "bob", "msg", "");
1738 assert!(
1739 info.timestamp.contains('T') || info.timestamp.contains('Z'),
1740 "expected RFC 3339 timestamp, got: {}",
1741 info.timestamp
1742 );
1743 }
1744}