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
49const PORTALS_CLOUD_URL: &str = "grpcs://lore.portals.works";
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 event_string(data: &serde_json::Value, field: &str) -> Result<String, String> {
186 data.get(field)
187 .and_then(serde_json::Value::as_str)
188 .filter(|value| !value.is_empty())
189 .map(str::to_owned)
190 .ok_or_else(|| format!("Lore {field} is missing or is not a string"))
191}
192
193fn validate_lower_hex(value: &str, bytes: usize, label: &str) -> Result<(), String> {
194 if value.len() != bytes * 2 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
195 return Err(format!("Lore returned an invalid {label}"));
196 }
197 Ok(())
198}
199
200fn hydrate_lore_file(
201 repo_path: &Path,
202 args: impl IntoIterator<Item = String>,
203 prefix: &str,
204) -> Result<Vec<u8>, NapError> {
205 let temp_dir = tempfile::Builder::new()
208 .prefix(&format!("nap-{prefix}-"))
209 .tempdir()
210 .map_err(|e| NapError::VcsError(format!("failed to create private temp directory: {e}")))?;
211 let output_path = temp_dir.path().join("content");
212 let output = output_path.to_string_lossy().into_owned();
213 let mut command_args: Vec<String> = args.into_iter().collect();
214 command_args.extend([
215 "--output".to_string(),
216 output,
217 "--non-interactive".to_string(),
218 ]);
219 LoreProcessRunner::run(command_args, Some(repo_path))?;
220 std::fs::read(&output_path).map_err(|e| {
221 NapError::VcsError(format!(
222 "failed to read Lore output {}: {e}",
223 output_path.display()
224 ))
225 })
226}
227
228fn parse_metadata_output(stdout: &str) -> Result<BTreeMap<String, String>, String> {
229 if let Ok(value) = serde_json::from_str::<serde_json::Value>(stdout) {
230 let mut metadata = BTreeMap::new();
231 if let serde_json::Value::Object(map) = value {
232 for (key, value) in map {
233 let rendered = match value {
234 serde_json::Value::String(s) => s,
235 serde_json::Value::Bool(b) => b.to_string(),
236 serde_json::Value::Number(n) => n.to_string(),
237 serde_json::Value::Null => continue,
238 other => serde_json::to_string(&other).map_err(|e| e.to_string())?,
239 };
240 metadata.insert(key, rendered);
241 }
242 }
243 return Ok(metadata);
244 }
245
246 let mut metadata = BTreeMap::new();
247 for line in stdout.lines() {
248 let trimmed = line.trim();
249 if trimmed.is_empty() {
250 continue;
251 }
252 if let Some((key, value)) = trimmed.split_once('=').or_else(|| trimmed.split_once(':')) {
253 let key = key.trim();
254 if !key.is_empty() {
255 metadata.insert(key.to_string(), value.trim().to_string());
256 }
257 }
258 }
259 Ok(metadata)
260}
261
262#[derive(Debug, Clone)]
275pub struct LoreBackend {
276 remote_url: String,
278 workspace_id: String,
280}
281
282impl LoreBackend {
283 pub fn new(remote_url: &str, workspace_id: &str) -> Self {
288 Self {
289 remote_url: remote_url.to_string(),
290 workspace_id: workspace_id.to_string(),
291 }
292 }
293
294 pub fn remote_url(&self) -> &str {
295 &self.remote_url
296 }
297
298 pub fn clone_repo(url: &str, dest: &Path) -> Result<(), NapError> {
304 LoreProcessRunner::run(
305 [
306 "clone",
307 url,
308 dest.to_str().unwrap_or("."),
309 "--non-interactive",
310 ],
311 None,
312 )?;
313 Ok(())
314 }
315
316 pub fn from_env() -> Self {
329 if let Ok(nap_dir) = std::env::var("NAP_DIR") {
331 let manager = crate::server::manager::ServerManager::new(Path::new(&nap_dir));
332 let _ = tokio::runtime::Handle::try_current().map(|handle| {
333 handle.block_on(async {
334 let _ = manager.ensure_running().await;
335 });
336 });
337 }
338
339 let url_from_env = std::env::var("NAP_LORE_URL_BASE").ok();
341 let workspace_from_env = std::env::var("NAP_WORKSPACE_ID").ok();
342
343 if url_from_env.is_some() || workspace_from_env.is_some() {
344 let base = url_from_env.unwrap_or_else(|| "lore://localhost:41337".to_string());
345 let workspace_id = workspace_from_env.unwrap_or_else(|| "default".to_string());
346 tracing::debug!(
347 url_base = %base,
348 workspace_id = %workspace_id,
349 "LoreBackend::from_env using environment variables (override)"
350 );
351 return Self {
352 remote_url: base,
353 workspace_id,
354 };
355 }
356
357 if let Ok(base_dir_str) = std::env::var("NAP_INIT_BASE_DIR") {
361 let base_path = PathBuf::from(&base_dir_str);
362 let provider_config_path = base_path.join("provider.toml");
363 if provider_config_path.exists()
364 && let Ok(config_content) = std::fs::read_to_string(&provider_config_path)
365 && let Ok(config) = toml::from_str::<ProviderConfigToml>(&config_content)
366 {
367 match config.provider_type.as_str() {
368 "local" => {
369 tracing::debug!(
370 url_base = "lore://localhost:41337",
371 workspace_id = "default",
372 "LoreBackend::from_env using local provider from NAP_INIT_BASE_DIR"
373 );
374 return Self {
375 remote_url: "lore://localhost:41337".to_string(),
376 workspace_id: "default".to_string(),
377 };
378 }
379 "remote" => {
380 if let (Some(url), Some(workspace)) =
381 (config.remote_url, config.workspace_id)
382 {
383 tracing::debug!(
384 url_base = %url,
385 workspace_id = %workspace,
386 "LoreBackend::from_env using remote provider from NAP_INIT_BASE_DIR"
387 );
388 return Self {
389 remote_url: url,
390 workspace_id: workspace,
391 };
392 }
393 }
394 "portals-cloud" => {
395 let workspace_id =
396 config.workspace_id.unwrap_or_else(|| "default".to_string());
397 tracing::debug!(
398 url_base = %PORTALS_CLOUD_URL,
399 workspace_id = %workspace_id,
400 "LoreBackend::from_env using portals-cloud provider from NAP_INIT_BASE_DIR"
401 );
402 return Self {
403 remote_url: PORTALS_CLOUD_URL.to_string(),
404 workspace_id,
405 };
406 }
407 _ => {}
408 }
409 }
410 }
411
412 let nap_dir = if let Ok(nap_dir_str) = std::env::var("NAP_DIR") {
414 let path = PathBuf::from(&nap_dir_str);
416 if let Some(s) = path.to_str() {
417 if let Some(stripped) = s.strip_prefix('~') {
418 let home = std::env::var("HOME")
419 .or_else(|_| std::env::var("USERPROFILE"))
420 .unwrap_or_else(|_| ".".to_string());
421 PathBuf::from(home).join(stripped.trim_start_matches('/'))
422 } else {
423 path
424 }
425 } else {
426 path
427 }
428 } else {
429 let home = std::env::var("HOME")
431 .or_else(|_| std::env::var("USERPROFILE"))
432 .unwrap_or_else(|_| ".".to_string());
433 PathBuf::from(home).join(".nap")
434 };
435
436 let provider_config_path = nap_dir.join("provider.toml");
437 if provider_config_path.exists()
438 && let Ok(config_content) = std::fs::read_to_string(&provider_config_path)
439 && let Ok(config) = toml::from_str::<ProviderConfigToml>(&config_content)
440 {
441 match config.provider_type.as_str() {
442 "local" => {
443 tracing::debug!(
445 url_base = "lore://localhost:41337",
446 workspace_id = "default",
447 "LoreBackend::from_env using local provider configuration"
448 );
449 return Self {
450 remote_url: "lore://localhost:41337".to_string(),
451 workspace_id: "default".to_string(),
452 };
453 }
454 "remote" => {
455 if let (Some(url), Some(workspace)) = (config.remote_url, config.workspace_id) {
457 tracing::debug!(
458 url_base = %url,
459 workspace_id = %workspace,
460 "LoreBackend::from_env using remote provider configuration"
461 );
462 return Self {
463 remote_url: url,
464 workspace_id: workspace,
465 };
466 }
467 }
468 "portals-cloud" => {
469 let workspace_id = config.workspace_id.unwrap_or_else(|| "default".to_string());
471 tracing::debug!(
472 url_base = %PORTALS_CLOUD_URL,
473 workspace_id = %workspace_id,
474 "LoreBackend::from_env using portals-cloud provider configuration"
475 );
476 return Self {
477 remote_url: PORTALS_CLOUD_URL.to_string(),
478 workspace_id,
479 };
480 }
481 _ => {
482 tracing::debug!(
483 provider_type = %config.provider_type,
484 "Unknown provider type, falling back to defaults"
485 );
486 }
487 }
488 }
489
490 let base = "lore://localhost:41337".to_string();
492 let workspace_id = "default".to_string();
493 tracing::debug!(
494 url_base = %base,
495 workspace_id = %workspace_id,
496 "LoreBackend::from_env using defaults"
497 );
498 Self {
499 remote_url: base,
500 workspace_id,
501 }
502 }
503
504 pub fn from_provider(url_base: &str, workspace_id: &str) -> Self {
508 tracing::debug!(
509 url_base = %url_base,
510 workspace_id = %workspace_id,
511 "Creating LoreBackend from provider configuration"
512 );
513
514 Self {
515 remote_url: url_base.to_string(),
516 workspace_id: workspace_id.to_string(),
517 }
518 }
519
520 fn repo_url(&self, repo_id: &str) -> String {
522 format!("{}/{}", self.remote_url.trim_end_matches('/'), repo_id)
523 }
524}
525
526impl VcsBackend for LoreBackend {
527 fn remote_url_base(&self) -> Result<String, NapError> {
529 Ok(self.remote_url.clone())
530 }
531
532 fn init(&self, path: &Path) -> Result<(), NapError> {
534 let raw_id = path
543 .file_name()
544 .and_then(|n| n.to_str())
545 .unwrap_or("nap-repo");
546 let repo_id = {
555 let from_manifest = path
556 .join("repository.yaml")
557 .exists()
558 .then(|| {
559 std::fs::read_to_string(path.join("repository.yaml"))
560 .ok()
561 .and_then(|c| {
562 serde_yaml::from_str::<serde_yaml::Value>(&c)
563 .ok()
564 .and_then(|v| {
565 v.get("id").and_then(|id| id.as_str()).and_then(|id_str| {
566 id_str.strip_prefix("nap://").and_then(|rest| {
568 rest.split('/').next().map(|s| s.to_string())
569 })
570 })
571 })
572 })
573 })
574 .flatten()
575 .filter(|s| {
576 !s.is_empty()
577 && s.chars()
578 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
579 });
580 from_manifest.unwrap_or_else(|| {
581 let sanitized = raw_id.trim_start_matches(['.', '_']);
582 if sanitized.is_empty()
583 || !sanitized
584 .chars()
585 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
586 {
587 "nap-repo".to_string()
588 } else {
589 sanitized.to_string()
590 }
591 })
592 };
593
594 let url = self.repo_url(&repo_id);
595 let path_str = path.to_str().unwrap_or(".");
596
597 let server_path = path
599 .parent()
600 .unwrap_or(path)
601 .join(".lore-server")
602 .join(repo_id);
603
604 LoreProcessRunner::run(
606 [
607 "repository",
608 "create",
609 &url,
610 "--id",
611 &self.workspace_id,
612 "--repository",
613 server_path.to_str().unwrap_or("."),
614 "--non-interactive",
615 ],
616 None,
617 )
618 .map_err(|e| {
619 NapError::VcsError(format!("failed to create lore repository '{}': {}", url, e))
620 })?;
621
622 LoreProcessRunner::run(["clone", &url, path_str, "--non-interactive"], None).map_err(
624 |e| {
625 NapError::VcsError(format!(
626 "failed to clone lore repository to {:?}: {}",
627 path, e
628 ))
629 },
630 )?;
631
632 Ok(())
633 }
634
635 fn commit(&self, path: &Path, message: &str, author: &str) -> Result<String, NapError> {
637 LoreProcessRunner::run(["stage", "--scan", ".", "--non-interactive"], Some(path))?;
640
641 let stdout = LoreProcessRunner::run(
643 [
644 "revision",
645 "commit",
646 message,
647 "--identity",
648 author,
649 "--non-interactive",
650 ],
651 Some(path),
652 )?;
653
654 let signature = stdout
657 .lines()
658 .find_map(|line| {
659 line.strip_prefix("Signature :")
660 .or_else(|| line.strip_prefix("Signature:"))
661 })
662 .map(|s| s.trim().to_string())
663 .unwrap_or_else(|| {
664 stdout
666 .lines()
667 .next()
668 .unwrap_or(&stdout)
669 .trim()
670 .strip_prefix("Created revision ")
671 .and_then(|s| s.split_whitespace().next())
672 .map(|s| s.to_string())
673 .unwrap_or_else(|| stdout.trim().to_string())
674 });
675
676 Ok(signature)
677 }
678
679 fn read_file_at_ref(
681 &self,
682 repo_path: &Path,
683 file_path: &str,
684 reference: Option<&str>,
685 ) -> Result<String, NapError> {
686 let bytes = self.read_file_bytes_at_ref(repo_path, file_path, reference)?;
687 String::from_utf8(bytes).map_err(|e| {
688 NapError::VcsError(format!(
689 "{} is not valid UTF-8; use read_file_bytes_at_ref for binary content: {e}",
690 file_path
691 ))
692 })
693 }
694
695 fn read_file_bytes_at_ref(
696 &self,
697 repo_path: &Path,
698 file_path: &str,
699 reference: Option<&str>,
700 ) -> Result<Vec<u8>, NapError> {
701 let Some(reference) = reference else {
702 let full_path = repo_path.join(file_path);
703 return std::fs::read(&full_path).map_err(|e| {
704 NapError::VcsError(format!("failed to read {}: {e}", full_path.display()))
705 });
706 };
707
708 hydrate_lore_file(
709 repo_path,
710 [
711 "file".to_string(),
712 "write".to_string(),
713 "--path".to_string(),
714 file_path.to_string(),
715 "--revision".to_string(),
716 reference.to_string(),
717 ],
718 "file-at-ref",
719 )
720 }
721
722 fn repository_descriptor(&self, repo_path: &Path) -> Result<VcsRepositoryDescriptor, NapError> {
723 let stdout = LoreProcessRunner::run(
724 ["repository", "info", "--json", "--non-interactive"],
725 Some(repo_path),
726 )?;
727 let data = parse_lore_event_data(&stdout, "repositoryData").map_err(|e| {
728 NapError::VcsError(format!("failed to parse Lore repository info: {e}"))
729 })?;
730 let id = event_string(&data, "id").map_err(|e| {
731 NapError::VcsError(format!("failed to parse Lore repository info: {e}"))
732 })?;
733 validate_lower_hex(&id, 16, "repository ID").map_err(|e| {
734 NapError::VcsError(format!("failed to parse Lore repository info: {e}"))
735 })?;
736 let remote_url = data
737 .get("remoteUrl")
738 .and_then(serde_json::Value::as_str)
739 .unwrap_or_default()
740 .to_string();
741 Ok(VcsRepositoryDescriptor { id, remote_url })
742 }
743
744 fn file_content_address_at_ref(
745 &self,
746 repo_path: &Path,
747 file_path: &str,
748 reference: &str,
749 ) -> Result<VcsContentAddress, NapError> {
750 let stdout = LoreProcessRunner::run(
751 [
752 "file",
753 "info",
754 file_path,
755 "--revision",
756 reference,
757 "--json",
758 "--non-interactive",
759 ],
760 Some(repo_path),
761 )?;
762 let data = parse_lore_event_data(&stdout, "fileInfo")
763 .map_err(|e| NapError::VcsError(format!("failed to parse Lore file info: {e}")))?;
764 if data.get("isFile").and_then(serde_json::Value::as_bool) != Some(true) {
765 return Err(NapError::VcsError(format!(
766 "representation path '{file_path}' is not a file at revision '{reference}'"
767 )));
768 }
769 let hash = event_string(&data, "hash")
770 .map_err(|e| NapError::VcsError(format!("failed to parse Lore file info: {e}")))?;
771 let context = event_string(&data, "context")
772 .map_err(|e| NapError::VcsError(format!("failed to parse Lore file info: {e}")))?;
773 validate_lower_hex(&hash, 32, "file hash")
774 .and_then(|_| validate_lower_hex(&context, 16, "file context"))
775 .map_err(|e| NapError::VcsError(format!("failed to parse Lore file info: {e}")))?;
776 Ok(VcsContentAddress { hash, context })
777 }
778
779 fn file_metadata_at_ref(
781 &self,
782 repo_path: &Path,
783 file_path: &str,
784 reference: &str,
785 ) -> Result<Option<BTreeMap<String, String>>, NapError> {
786 let stdout = LoreProcessRunner::run(
787 [
788 "file",
789 "metadata",
790 "get",
791 file_path,
792 "--revision",
793 reference,
794 "--non-interactive",
795 ],
796 Some(repo_path),
797 )?;
798
799 if stdout.trim().is_empty() || stdout.trim() == "null" {
800 return Ok(None);
801 }
802
803 parse_metadata_output(&stdout)
804 .map(Some)
805 .map_err(|e| NapError::VcsError(format!("failed to parse lore file metadata: {e}")))
806 }
807
808 fn read_provenance_blob(&self, repo_path: &Path, address: &str) -> Result<String, NapError> {
809 let bytes = hydrate_lore_file(
810 repo_path,
811 [
812 "file".to_string(),
813 "write".to_string(),
814 "--address".to_string(),
815 address.to_string(),
816 ],
817 "provenance-blob",
818 )?;
819 String::from_utf8(bytes).map_err(|e| {
820 NapError::VcsError(format!(
821 "hydrated provenance blob {address} is not valid UTF-8: {e}"
822 ))
823 })
824 }
825
826 fn log(
828 &self,
829 path: &Path,
830 _file: Option<&str>,
831 limit: usize,
832 ) -> Result<Vec<CommitInfo>, NapError> {
833 let limit_str = limit.to_string();
834 let args = vec!["history", &limit_str, "--non-interactive"];
835
836 let stdout = LoreProcessRunner::run(&args, Some(path))?;
837
838 if stdout.trim().is_empty() {
839 return Ok(Vec::new());
840 }
841
842 let mut commits = Vec::new();
851 let mut current_signature = String::new();
852 let mut current_author = String::new();
853 let mut current_message = String::new();
854 let mut current_timestamp = String::new();
855 let mut current_parent: Option<String> = None;
856 let mut in_message = false;
857
858 for line in stdout.lines() {
859 let trimmed = line.trim();
860 if trimmed.starts_with("Signature :") || trimmed.starts_with("Signature:") {
861 if !current_signature.is_empty() {
863 commits.push(CommitInfo {
864 id: std::mem::take(&mut current_signature),
865 parent: current_parent.take(),
866 author: std::mem::take(&mut current_author),
867 message: std::mem::take(&mut current_message),
868 timestamp: std::mem::take(&mut current_timestamp),
869 });
870 }
871 current_signature = trimmed
872 .strip_prefix("Signature :")
873 .or_else(|| trimmed.strip_prefix("Signature:"))
874 .unwrap_or("")
875 .trim()
876 .to_string();
877 in_message = false;
878 } else if trimmed.starts_with("Date :") || trimmed.starts_with("Date:") {
879 current_timestamp = trimmed
880 .split_once(':')
881 .map(|(_, v)| v.trim().to_string())
882 .unwrap_or_default();
883 in_message = true;
884 } else if trimmed.starts_with("Creator :") || trimmed.starts_with("Creator:") {
885 current_author = trimmed
886 .split_once(':')
887 .map(|(_, v)| v.trim().to_string())
888 .unwrap_or_default();
889 in_message = false;
890 } else if trimmed.starts_with("Revision :")
891 || trimmed.starts_with("Revision:")
892 || trimmed.starts_with("Branch :")
893 || trimmed.starts_with("Branch:")
894 || trimmed.starts_with("Committer :")
895 || trimmed.starts_with("Committer:")
896 {
897 in_message = false;
898 } else if in_message {
899 if trimmed.is_empty() || trimmed == "Commit succeeded" {
900 in_message = false;
901 } else {
902 if !current_message.is_empty() {
903 current_message.push('\n');
904 }
905 current_message.push_str(trimmed);
906 }
907 }
908 }
909 if !current_signature.is_empty() {
911 commits.push(CommitInfo {
912 id: current_signature,
913 parent: current_parent,
914 author: current_author,
915 message: current_message,
916 timestamp: current_timestamp,
917 });
918 }
919
920 Ok(commits)
921 }
922
923 fn create_branch(&self, path: &Path, name: &str) -> Result<(), NapError> {
925 LoreProcessRunner::run(["branch", "create", name, "--non-interactive"], Some(path))?;
926 Ok(())
927 }
928
929 fn switch_branch(&self, path: &Path, name: &str) -> Result<(), NapError> {
930 LoreProcessRunner::run(["branch", "switch", name, "--non-interactive"], Some(path))?;
931 Ok(())
932 }
933
934 fn current_branch(&self, path: &Path) -> Result<String, NapError> {
935 let stdout = LoreProcessRunner::run(["branch", "show", "--non-interactive"], Some(path))?;
936 Ok(stdout.trim().to_string())
937 }
938
939 fn list_branches(&self, path: &Path) -> Result<Vec<String>, NapError> {
940 let stdout = LoreProcessRunner::run(["branch", "list", "--non-interactive"], Some(path))?;
941 if stdout.is_empty() {
942 return Ok(Vec::new());
943 }
944 let mut branches = Vec::new();
951 let mut in_local = false;
952 for line in stdout.lines() {
953 let trimmed = line.trim();
954 if trimmed.starts_with("Local branches") {
955 in_local = true;
956 continue;
957 }
958 if trimmed.starts_with("Remote branches") {
959 in_local = false;
960 continue;
961 }
962 if in_local && !trimmed.is_empty() {
963 let name = trimmed.strip_prefix("* ").unwrap_or(trimmed);
965 branches.push(name.to_string());
966 }
967 }
968 Ok(branches)
969 }
970
971 fn head_hash(&self, path: &Path) -> Result<String, NapError> {
973 let stdout = LoreProcessRunner::run(["history", "1", "--non-interactive"], Some(path))?;
974
975 if stdout.trim().is_empty() {
976 return Err(NapError::VcsError(
977 "no commits in lore workspace".to_string(),
978 ));
979 }
980
981 stdout
983 .lines()
984 .find_map(|line| {
985 line.trim()
986 .strip_prefix("Signature :")
987 .or_else(|| line.trim().strip_prefix("Signature:"))
988 })
989 .map(|s| s.trim().to_string())
990 .ok_or_else(|| {
991 NapError::VcsError(format!(
992 "failed to parse signature from lore history: {stdout}"
993 ))
994 })
995 }
996
997 fn revert(&self, path: &Path, commit_hash: &str) -> Result<String, NapError> {
998 let stdout = LoreProcessRunner::run(
999 ["revision", "revert", commit_hash, "--non-interactive"],
1000 Some(path),
1001 )?;
1002 let signature = stdout
1004 .trim()
1005 .strip_prefix("Created revert revision ")
1006 .unwrap_or(stdout.trim());
1007 Ok(signature.to_string())
1008 }
1009
1010 fn resolve_branch_head(&self, path: &Path, branch: &str) -> Result<String, NapError> {
1011 let stdout = LoreProcessRunner::run(
1012 ["history", "1", "--branch", branch, "--non-interactive"],
1013 Some(path),
1014 )?;
1015
1016 if stdout.trim().is_empty() {
1017 return Err(NapError::VcsError(format!(
1018 "no commits found on branch '{branch}'"
1019 )));
1020 }
1021
1022 stdout
1024 .lines()
1025 .find_map(|line| {
1026 line.trim()
1027 .strip_prefix("Signature :")
1028 .or_else(|| line.trim().strip_prefix("Signature:"))
1029 })
1030 .map(|s| s.trim().to_string())
1031 .ok_or_else(|| {
1032 NapError::VcsError(format!(
1033 "failed to parse signature from lore history on branch '{branch}': {stdout}"
1034 ))
1035 })
1036 }
1037
1038 fn add_remote(&self, path: &Path, name: &str, url: &str) -> Result<(), NapError> {
1042 let remotes_path = path.join(".lore").join("remotes.toml");
1043 let mut map: std::collections::BTreeMap<String, String> = if remotes_path.exists() {
1044 let content = std::fs::read_to_string(&remotes_path).unwrap_or_default();
1045 toml::from_str(&content).unwrap_or_default()
1046 } else {
1047 std::collections::BTreeMap::new()
1048 };
1049 map.insert(name.to_string(), url.to_string());
1050 if let Some(parent) = remotes_path.parent() {
1051 std::fs::create_dir_all(parent).map_err(|e| NapError::VcsError(e.to_string()))?;
1052 }
1053 let content = toml::to_string(&map).map_err(|e| NapError::VcsError(e.to_string()))?;
1054 std::fs::write(&remotes_path, content).map_err(|e| NapError::VcsError(e.to_string()))?;
1055 Ok(())
1056 }
1057
1058 fn remove_remote(&self, path: &Path, name: &str) -> Result<(), NapError> {
1059 let remotes_path = path.join(".lore").join("remotes.toml");
1060 if !remotes_path.exists() {
1061 return Ok(());
1062 }
1063 let content = std::fs::read_to_string(&remotes_path).unwrap_or_default();
1064 let mut map: std::collections::BTreeMap<String, String> =
1065 toml::from_str(&content).unwrap_or_default();
1066 map.remove(name);
1067 let new_content = toml::to_string(&map).map_err(|e| NapError::VcsError(e.to_string()))?;
1068 std::fs::write(&remotes_path, new_content)
1069 .map_err(|e| NapError::VcsError(e.to_string()))?;
1070 Ok(())
1071 }
1072
1073 fn list_remotes(&self, path: &Path) -> Result<Vec<(String, String)>, NapError> {
1074 let remotes_path = path.join(".lore").join("remotes.toml");
1075 if !remotes_path.exists() {
1076 return Ok(Vec::new());
1077 }
1078 let content = std::fs::read_to_string(&remotes_path).unwrap_or_default();
1079 let map: std::collections::BTreeMap<String, String> =
1080 toml::from_str(&content).unwrap_or_default();
1081 Ok(map.into_iter().collect())
1082 }
1083
1084 fn push(
1086 &self,
1087 path: &Path,
1088 _remote: Option<&str>,
1089 branch: Option<&str>,
1090 ) -> Result<(), NapError> {
1091 let branch_name = match branch {
1094 Some(b) => b.to_string(),
1095 None => self
1096 .current_branch(path)
1097 .unwrap_or_else(|_| "main".to_string()),
1098 };
1099
1100 let args = vec![
1102 "branch",
1103 "push",
1104 &branch_name,
1105 "--fast-forward-merge",
1106 "--non-interactive",
1107 ];
1108 LoreProcessRunner::run(&args, Some(path))?;
1109
1110 Ok(())
1111 }
1112
1113 fn pull(
1114 &self,
1115 path: &Path,
1116 _remote: Option<&str>,
1117 _branch: Option<&str>,
1118 ) -> Result<(), NapError> {
1119 let args = vec!["sync", "--non-interactive", "--reset"];
1121 LoreProcessRunner::run(&args, Some(path))?;
1122
1123 Ok(())
1124 }
1125}
1126
1127#[cfg(test)]
1132mod structured_output_tests {
1133 use super::*;
1134
1135 #[test]
1136 fn parses_repository_and_file_events_independently() {
1137 let repository = concat!(
1138 "{\"tagName\":\"repositoryData\",\"data\":{",
1139 "\"id\":\"0123456789abcdef0123456789abcdef\",",
1140 "\"remoteUrl\":\"lore://localhost:41337/repo\"}}\n",
1141 "{\"tagName\":\"complete\",\"data\":{}}"
1142 );
1143 let file = concat!(
1144 "{\"tagName\":\"fileInfo\",\"data\":{",
1145 "\"hash\":\"9753abf79e5aef60bd95ab76c1e5a14d01239beb37ff9897b6af8e040eb2413a\",",
1146 "\"context\":\"fedcba9876543210fedcba9876543210\",\"isFile\":true}}"
1147 );
1148 let repository_data = parse_lore_event_data(repository, "repositoryData").unwrap();
1149 let file_data = parse_lore_event_data(file, "fileInfo").unwrap();
1150 assert_eq!(
1151 event_string(&repository_data, "id").unwrap(),
1152 "0123456789abcdef0123456789abcdef"
1153 );
1154 assert_eq!(
1155 event_string(&file_data, "context").unwrap(),
1156 "fedcba9876543210fedcba9876543210"
1157 );
1158 }
1159
1160 #[test]
1161 fn rejects_duplicate_or_malformed_events() {
1162 let duplicate =
1163 "{\"tagName\":\"fileInfo\",\"data\":{}}\n{\"tagName\":\"fileInfo\",\"data\":{}}";
1164 assert!(parse_lore_event_data(duplicate, "fileInfo").is_err());
1165 assert!(parse_lore_event_data("not-json", "fileInfo").is_err());
1166 assert!(validate_lower_hex("abc", 16, "context").is_err());
1167 }
1168
1169 #[test]
1170 fn working_tree_binary_reads_are_lossless() {
1171 let temp = tempfile::TempDir::new().unwrap();
1172 let bytes = [0_u8, 0xff, 0x42];
1173 std::fs::write(temp.path().join("asset.bin"), bytes).unwrap();
1174 let backend = LoreBackend::from_env();
1175 assert_eq!(
1176 backend
1177 .read_file_bytes_at_ref(temp.path(), "asset.bin", None)
1178 .unwrap(),
1179 bytes
1180 );
1181 assert!(
1182 backend
1183 .read_file_at_ref(temp.path(), "asset.bin", None)
1184 .unwrap_err()
1185 .to_string()
1186 .contains("read_file_bytes_at_ref")
1187 );
1188 }
1189}
1190
1191#[cfg(all(test, feature = "lore-integration"))]
1192mod tests {
1193 use super::*;
1194
1195 #[test]
1198 fn test_binary_default() {
1199 assert_eq!(LoreProcessRunner::binary(), "lore");
1200 }
1201
1202 #[test]
1203 fn test_binary_from_env() {
1204 temp_env::with_var("NAPLORE_CLI", Some("/custom/lore"), || {
1205 assert_eq!(LoreProcessRunner::binary(), "/custom/lore");
1206 });
1207 }
1208
1209 #[test]
1210 fn test_run_captures_stdout() {
1211 temp_env::with_var("NAPLORE_CLI", Some("lore-nonexistent-binary-12345"), || {
1215 let result = LoreProcessRunner::run(["--version"], None);
1216 assert!(result.is_err());
1217 let err = result.unwrap_err().to_string();
1218 assert!(
1219 err.contains("lore-nonexistent-binary-12345"),
1220 "error: {}",
1221 err
1222 );
1223 });
1224 }
1225
1226 #[test]
1229 fn test_new_and_from_env() {
1230 let backend = LoreBackend::new("lore://myhost:8700", "test-workspace");
1231 assert_eq!(backend.remote_url, "lore://myhost:8700");
1232 assert_eq!(backend.workspace_id, "test-workspace");
1233
1234 temp_env::with_vars(
1235 vec![
1236 ("NAP_LORE_URL_BASE", Some("lore://custom:9999")),
1237 ("NAP_WORKSPACE_ID", Some("custom-ws")),
1238 ],
1239 || {
1240 let from_env = LoreBackend::from_env();
1241 assert_eq!(from_env.remote_url, "lore://custom:9999");
1242 assert_eq!(from_env.workspace_id, "custom-ws");
1243 },
1244 );
1245 }
1246
1247 #[test]
1248 fn test_from_env_default_without_env_vars() {
1249 let temp_dir = tempfile::TempDir::new().unwrap();
1251 let nap_dir_str = temp_dir.path().to_str().unwrap();
1252
1253 temp_env::with_vars(
1254 vec![
1255 ("NAP_LORE_URL_BASE", None::<&str>),
1256 ("NAP_WORKSPACE_ID", None::<&str>),
1257 ("NAP_DIR", Some(nap_dir_str)),
1258 ],
1259 || {
1260 let backend = LoreBackend::from_env();
1261 assert_eq!(backend.remote_url, "lore://localhost:41337");
1262 assert_eq!(backend.workspace_id, "default");
1263 },
1264 );
1265 }
1266
1267 #[test]
1268 fn test_from_env_env_var_override() {
1269 let temp_dir = tempfile::TempDir::new().unwrap();
1271 let nap_dir_str = temp_dir.path().to_str().unwrap();
1272
1273 temp_env::with_vars(
1274 vec![
1275 ("NAP_LORE_URL_BASE", Some("lore://override:1234")),
1276 ("NAP_WORKSPACE_ID", Some("override-ws")),
1277 ("NAP_DIR", Some(nap_dir_str)),
1278 ],
1279 || {
1280 let backend = LoreBackend::from_env();
1281 assert_eq!(backend.remote_url, "lore://override:1234");
1282 assert_eq!(backend.workspace_id, "override-ws");
1283 },
1284 );
1285 }
1286
1287 #[test]
1288 fn test_from_env_partial_env_override() {
1289 let temp_dir = tempfile::TempDir::new().unwrap();
1291 let nap_dir_str = temp_dir.path().to_str().unwrap();
1292
1293 temp_env::with_vars(
1294 vec![
1295 ("NAP_LORE_URL_BASE", Some("lore://partial:5678")),
1296 ("NAP_WORKSPACE_ID", None::<&str>),
1297 ("NAP_DIR", Some(nap_dir_str)),
1298 ],
1299 || {
1300 let backend = LoreBackend::from_env();
1301 assert_eq!(backend.remote_url, "lore://partial:5678");
1302 assert_eq!(backend.workspace_id, "default");
1303 },
1304 );
1305 }
1306
1307 #[test]
1308 fn test_from_env_provider_config() {
1309 let temp_dir = tempfile::TempDir::new().unwrap();
1311 let provider_config = temp_dir.path().join("provider.toml");
1312 std::fs::write(
1313 &provider_config,
1314 r#"
1315provider_type = "remote"
1316remote_url = "lore://provider:9999"
1317workspace_id = "provider-ws"
1318"#,
1319 )
1320 .unwrap();
1321
1322 let nap_dir_str = temp_dir.path().to_str().unwrap();
1323 temp_env::with_vars(
1324 vec![
1325 ("NAP_LORE_URL_BASE", None::<&str>),
1326 ("NAP_WORKSPACE_ID", None::<&str>),
1327 ("NAP_DIR", Some(nap_dir_str)),
1328 ],
1329 || {
1330 let backend = LoreBackend::from_env();
1331 assert_eq!(backend.remote_url, "lore://provider:9999");
1332 assert_eq!(backend.workspace_id, "provider-ws");
1333 },
1334 );
1335 }
1336
1337 #[test]
1338 fn test_from_env_nap_dir_with_tilde() {
1339 let _home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
1341 let temp_dir = tempfile::TempDir::new().unwrap();
1342 let nap_dir_str = temp_dir.path().to_str().unwrap();
1343
1344 temp_env::with_vars(
1345 vec![
1346 ("NAP_LORE_URL_BASE", None::<&str>),
1347 ("NAP_WORKSPACE_ID", None::<&str>),
1348 ("NAP_DIR", Some(nap_dir_str)),
1349 ],
1350 || {
1351 let backend = LoreBackend::from_env();
1352 assert_eq!(backend.remote_url, "lore://localhost:41337");
1354 assert_eq!(backend.workspace_id, "default");
1355 },
1356 );
1357 }
1358
1359 #[test]
1360 fn test_from_env_local_provider_config() {
1361 let temp_dir = tempfile::TempDir::new().unwrap();
1363 let provider_config = temp_dir.path().join("provider.toml");
1364 std::fs::write(
1365 &provider_config,
1366 r#"
1367provider_type = "local"
1368"#,
1369 )
1370 .unwrap();
1371
1372 let nap_dir_str = temp_dir.path().to_str().unwrap();
1373 temp_env::with_vars(
1374 vec![
1375 ("NAP_LORE_URL_BASE", None::<&str>),
1376 ("NAP_WORKSPACE_ID", None::<&str>),
1377 ("NAP_DIR", Some(nap_dir_str)),
1378 ],
1379 || {
1380 let backend = LoreBackend::from_env();
1381 assert_eq!(backend.remote_url, "lore://localhost:41337");
1382 assert_eq!(backend.workspace_id, "default");
1383 },
1384 );
1385 }
1386
1387 #[test]
1388 fn test_from_env_portals_cloud_provider_config() {
1389 let temp_dir = tempfile::TempDir::new().unwrap();
1391 let provider_config = temp_dir.path().join("provider.toml");
1392 std::fs::write(
1393 &provider_config,
1394 r#"
1395provider_type = "portals-cloud"
1396workspace_id = "cloud-ws"
1397"#,
1398 )
1399 .unwrap();
1400
1401 let nap_dir_str = temp_dir.path().to_str().unwrap();
1402 temp_env::with_vars(
1403 vec![
1404 ("NAP_LORE_URL_BASE", None::<&str>),
1405 ("NAP_WORKSPACE_ID", None::<&str>),
1406 ("NAP_DIR", Some(nap_dir_str)),
1407 ],
1408 || {
1409 let backend = LoreBackend::from_env();
1410 assert_eq!(backend.remote_url, PORTALS_CLOUD_URL);
1411 assert_eq!(backend.workspace_id, "cloud-ws");
1412 },
1413 );
1414 }
1415
1416 #[test]
1417 fn test_from_env_portals_cloud_default_workspace() {
1418 let temp_dir = tempfile::TempDir::new().unwrap();
1420 let provider_config = temp_dir.path().join("provider.toml");
1421 std::fs::write(
1422 &provider_config,
1423 r#"
1424provider_type = "portals-cloud"
1425"#,
1426 )
1427 .unwrap();
1428
1429 let nap_dir_str = temp_dir.path().to_str().unwrap();
1430 temp_env::with_vars(
1431 vec![
1432 ("NAP_LORE_URL_BASE", None::<&str>),
1433 ("NAP_WORKSPACE_ID", None::<&str>),
1434 ("NAP_DIR", Some(nap_dir_str)),
1435 ],
1436 || {
1437 let backend = LoreBackend::from_env();
1438 assert_eq!(backend.remote_url, PORTALS_CLOUD_URL);
1439 assert_eq!(backend.workspace_id, "default");
1440 },
1441 );
1442 }
1443
1444 #[test]
1445 fn test_from_env_unknown_provider_type() {
1446 let temp_dir = tempfile::TempDir::new().unwrap();
1448 let provider_config = temp_dir.path().join("provider.toml");
1449 std::fs::write(
1450 &provider_config,
1451 r#"
1452provider_type = "unknown-provider"
1453"#,
1454 )
1455 .unwrap();
1456
1457 let nap_dir_str = temp_dir.path().to_str().unwrap();
1458 temp_env::with_vars(
1459 vec![
1460 ("NAP_LORE_URL_BASE", None::<&str>),
1461 ("NAP_WORKSPACE_ID", None::<&str>),
1462 ("NAP_DIR", Some(nap_dir_str)),
1463 ],
1464 || {
1465 let backend = LoreBackend::from_env();
1466 assert_eq!(backend.remote_url, "lore://localhost:41337");
1467 assert_eq!(backend.workspace_id, "default");
1468 },
1469 );
1470 }
1471
1472 #[test]
1473 fn test_repo_url_joining() {
1474 let backend = LoreBackend::new("lore://localhost:8700", "ws");
1475 assert_eq!(backend.repo_url("my-repo"), "lore://localhost:8700/my-repo");
1476
1477 let backend2 = LoreBackend::new("lore://host:8700/", "ws");
1479 assert_eq!(backend2.repo_url("foo"), "lore://host:8700/foo");
1480 }
1481
1482 #[test]
1483 fn test_list_branches_empty_json() {
1484 }
1489
1490 #[test]
1491 fn test_commit_parses_signature_from_stdout() {
1492 let sample = "Created revision a1b2c3d4 (#42)";
1496 let signature = sample
1497 .strip_prefix("Created revision ")
1498 .and_then(|s| s.split_whitespace().next())
1499 .unwrap_or(sample);
1500 assert_eq!(signature, "a1b2c3d4");
1501 }
1502
1503 #[test]
1506 fn test_commit_info_from_lore_revision() {
1507 let info = CommitInfo::from_lore_revision(
1508 "sig123",
1509 Some("sig122"),
1510 "alice",
1511 "feat: add manifest",
1512 "2026-06-30T12:00:00Z",
1513 );
1514 assert_eq!(info.id, "sig123");
1515 assert_eq!(info.parent.as_deref(), Some("sig122"));
1516 assert_eq!(info.author, "alice");
1517 assert_eq!(info.message, "feat: add manifest");
1518 assert_eq!(info.timestamp, "2026-06-30T12:00:00Z");
1519 }
1520
1521 #[test]
1522 fn test_commit_info_default_timestamp() {
1523 let info = CommitInfo::from_lore_revision("sig", None, "bob", "msg", "");
1525 assert!(
1526 info.timestamp.contains('T') || info.timestamp.contains('Z'),
1527 "expected RFC 3339 timestamp, got: {}",
1528 info.timestamp
1529 );
1530 }
1531}