use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant;
use crate::error::NapError;
use crate::vcs::{CommitInfo, VcsBackend};
#[derive(serde::Deserialize)]
struct ProviderConfigToml {
provider_type: String,
remote_url: Option<String>,
workspace_id: Option<String>,
}
const PORTALS_CLOUD_URL: &str = "grpcs://lore.portals.works";
pub struct LoreProcessRunner;
impl LoreProcessRunner {
pub fn binary() -> String {
std::env::var("NAPLORE_CLI").unwrap_or_else(|_| "lore".to_string())
}
pub fn run<I, S>(args: I, cwd: Option<&Path>) -> Result<String, NapError>
where
I: IntoIterator<Item = S>,
S: AsRef<std::ffi::OsStr>,
{
let args_vec: Vec<String> = args
.into_iter()
.map(|s| s.as_ref().to_string_lossy().into_owned())
.collect();
let bin = Self::binary();
let mut cmd = Command::new(&bin);
cmd.args(&args_vec);
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
let start = Instant::now();
let output = cmd.output().map_err(|e| {
NapError::VcsError(format!(
"failed to execute `{}`: {}. Is `{}` installed and on $PATH?",
bin, e, bin
))
})?;
let duration = start.elapsed();
if duration > std::time::Duration::from_secs(5) {
tracing::warn!(
duration_ms = duration.as_millis(),
command = format!("{} {:?}", bin, args_vec),
"lore command took > 5s — check Lore server health"
);
}
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
return Ok(stdout);
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let exit_code = output.status.code().unwrap_or(-1);
let nap_err = match exit_code {
1 => {
if stderr.contains("not authenticated")
|| stderr.contains("authentication required")
|| stderr.contains("Unauthenticated")
{
NapError::VcsError(
"Portals Cloud authentication is required; run `nap auth login` in an interactive terminal and retry"
.to_string(),
)
} else if stderr.contains("not a lore workspace")
|| stderr.contains("not an initialised lore workspace")
{
NapError::VcsError(format!(
"not a lore workspace at {:?}",
cwd.unwrap_or(Path::new("."))
))
} else if stderr.contains("not found") {
NapError::VcsError(format!("path not found in lore workspace: {}", stderr))
} else {
NapError::VcsError(format!(
"lore CLI exited with code {}: {}",
exit_code, stderr
))
}
}
64..=126 => {
NapError::VcsError(format!(
"lore CLI configuration error ({}): {}",
exit_code, stderr
))
}
_ => NapError::VcsError(format!(
"lore CLI exited with code {}: {}",
exit_code, stderr
)),
};
Err(nap_err)
}
}
static TEMP_BLOB_COUNTER: AtomicU64 = AtomicU64::new(0);
fn temp_lore_output_path(prefix: &str) -> PathBuf {
let unique = TEMP_BLOB_COUNTER.fetch_add(1, Ordering::SeqCst);
std::env::temp_dir().join(format!(
"nap-{prefix}-{}-{}.tmp",
std::process::id(),
unique
))
}
fn parse_metadata_output(stdout: &str) -> Result<BTreeMap<String, String>, String> {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(stdout) {
let mut metadata = BTreeMap::new();
if let serde_json::Value::Object(map) = value {
for (key, value) in map {
let rendered = match value {
serde_json::Value::String(s) => s,
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Null => continue,
other => serde_json::to_string(&other).map_err(|e| e.to_string())?,
};
metadata.insert(key, rendered);
}
}
return Ok(metadata);
}
let mut metadata = BTreeMap::new();
for line in stdout.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Some((key, value)) = trimmed.split_once('=').or_else(|| trimmed.split_once(':')) {
let key = key.trim();
if !key.is_empty() {
metadata.insert(key.to_string(), value.trim().to_string());
}
}
}
Ok(metadata)
}
#[derive(Debug, Clone)]
pub struct LoreBackend {
remote_url: String,
workspace_id: String,
}
impl LoreBackend {
pub fn new(remote_url: &str, workspace_id: &str) -> Self {
Self {
remote_url: remote_url.to_string(),
workspace_id: workspace_id.to_string(),
}
}
pub fn remote_url(&self) -> &str {
&self.remote_url
}
pub fn clone_repo(url: &str, dest: &Path) -> Result<(), NapError> {
LoreProcessRunner::run(
[
"clone",
url,
dest.to_str().unwrap_or("."),
"--non-interactive",
],
None,
)?;
Ok(())
}
pub fn from_env() -> Self {
if let Ok(nap_dir) = std::env::var("NAP_DIR") {
let manager = crate::server::manager::ServerManager::new(Path::new(&nap_dir));
let _ = tokio::runtime::Handle::try_current().map(|handle| {
handle.block_on(async {
let _ = manager.ensure_running().await;
});
});
}
let url_from_env = std::env::var("NAP_LORE_URL_BASE").ok();
let workspace_from_env = std::env::var("NAP_WORKSPACE_ID").ok();
if url_from_env.is_some() || workspace_from_env.is_some() {
let base = url_from_env.unwrap_or_else(|| "lore://localhost:41337".to_string());
let workspace_id = workspace_from_env.unwrap_or_else(|| "default".to_string());
tracing::debug!(
url_base = %base,
workspace_id = %workspace_id,
"LoreBackend::from_env using environment variables (override)"
);
return Self {
remote_url: base,
workspace_id,
};
}
if let Ok(base_dir_str) = std::env::var("NAP_INIT_BASE_DIR") {
let base_path = PathBuf::from(&base_dir_str);
let provider_config_path = base_path.join("provider.toml");
if provider_config_path.exists()
&& let Ok(config_content) = std::fs::read_to_string(&provider_config_path)
&& let Ok(config) = toml::from_str::<ProviderConfigToml>(&config_content)
{
match config.provider_type.as_str() {
"local" => {
tracing::debug!(
url_base = "lore://localhost:41337",
workspace_id = "default",
"LoreBackend::from_env using local provider from NAP_INIT_BASE_DIR"
);
return Self {
remote_url: "lore://localhost:41337".to_string(),
workspace_id: "default".to_string(),
};
}
"remote" => {
if let (Some(url), Some(workspace)) =
(config.remote_url, config.workspace_id)
{
tracing::debug!(
url_base = %url,
workspace_id = %workspace,
"LoreBackend::from_env using remote provider from NAP_INIT_BASE_DIR"
);
return Self {
remote_url: url,
workspace_id: workspace,
};
}
}
"portals-cloud" => {
let workspace_id =
config.workspace_id.unwrap_or_else(|| "default".to_string());
tracing::debug!(
url_base = %PORTALS_CLOUD_URL,
workspace_id = %workspace_id,
"LoreBackend::from_env using portals-cloud provider from NAP_INIT_BASE_DIR"
);
return Self {
remote_url: PORTALS_CLOUD_URL.to_string(),
workspace_id,
};
}
_ => {}
}
}
}
let nap_dir = if let Ok(nap_dir_str) = std::env::var("NAP_DIR") {
let path = PathBuf::from(&nap_dir_str);
if let Some(s) = path.to_str() {
if let Some(stripped) = s.strip_prefix('~') {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(stripped.trim_start_matches('/'))
} else {
path
}
} else {
path
}
} else {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".nap")
};
let provider_config_path = nap_dir.join("provider.toml");
if provider_config_path.exists()
&& let Ok(config_content) = std::fs::read_to_string(&provider_config_path)
&& let Ok(config) = toml::from_str::<ProviderConfigToml>(&config_content)
{
match config.provider_type.as_str() {
"local" => {
tracing::debug!(
url_base = "lore://localhost:41337",
workspace_id = "default",
"LoreBackend::from_env using local provider configuration"
);
return Self {
remote_url: "lore://localhost:41337".to_string(),
workspace_id: "default".to_string(),
};
}
"remote" => {
if let (Some(url), Some(workspace)) = (config.remote_url, config.workspace_id) {
tracing::debug!(
url_base = %url,
workspace_id = %workspace,
"LoreBackend::from_env using remote provider configuration"
);
return Self {
remote_url: url,
workspace_id: workspace,
};
}
}
"portals-cloud" => {
let workspace_id = config.workspace_id.unwrap_or_else(|| "default".to_string());
tracing::debug!(
url_base = %PORTALS_CLOUD_URL,
workspace_id = %workspace_id,
"LoreBackend::from_env using portals-cloud provider configuration"
);
return Self {
remote_url: PORTALS_CLOUD_URL.to_string(),
workspace_id,
};
}
_ => {
tracing::debug!(
provider_type = %config.provider_type,
"Unknown provider type, falling back to defaults"
);
}
}
}
let base = "lore://localhost:41337".to_string();
let workspace_id = "default".to_string();
tracing::debug!(
url_base = %base,
workspace_id = %workspace_id,
"LoreBackend::from_env using defaults"
);
Self {
remote_url: base,
workspace_id,
}
}
pub fn from_provider(url_base: &str, workspace_id: &str) -> Self {
tracing::debug!(
url_base = %url_base,
workspace_id = %workspace_id,
"Creating LoreBackend from provider configuration"
);
Self {
remote_url: url_base.to_string(),
workspace_id: workspace_id.to_string(),
}
}
fn repo_url(&self, repo_id: &str) -> String {
format!("{}/{}", self.remote_url.trim_end_matches('/'), repo_id)
}
}
impl VcsBackend for LoreBackend {
fn remote_url_base(&self) -> Result<String, NapError> {
Ok(self.remote_url.clone())
}
fn init(&self, path: &Path) -> Result<(), NapError> {
let raw_id = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("nap-repo");
let repo_id = {
let from_manifest = path
.join("repository.yaml")
.exists()
.then(|| {
std::fs::read_to_string(path.join("repository.yaml"))
.ok()
.and_then(|c| {
serde_yaml::from_str::<serde_yaml::Value>(&c)
.ok()
.and_then(|v| {
v.get("id").and_then(|id| id.as_str()).and_then(|id_str| {
id_str.strip_prefix("nap://").and_then(|rest| {
rest.split('/').next().map(|s| s.to_string())
})
})
})
})
})
.flatten()
.filter(|s| {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
});
from_manifest.unwrap_or_else(|| {
let sanitized = raw_id.trim_start_matches(|c| c == '.' || c == '_');
if sanitized.is_empty()
|| !sanitized
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
"nap-repo".to_string()
} else {
sanitized.to_string()
}
})
};
let url = self.repo_url(&repo_id);
let path_str = path.to_str().unwrap_or(".");
let server_path = path
.parent()
.unwrap_or(path)
.join(".lore-server")
.join(repo_id);
LoreProcessRunner::run(
[
"repository",
"create",
&url,
"--id",
&self.workspace_id,
"--repository",
server_path.to_str().unwrap_or("."),
"--non-interactive",
],
None,
)
.map_err(|e| {
NapError::VcsError(format!("failed to create lore repository '{}': {}", url, e))
})?;
LoreProcessRunner::run(["clone", &url, path_str, "--non-interactive"], None).map_err(
|e| {
NapError::VcsError(format!(
"failed to clone lore repository to {:?}: {}",
path, e
))
},
)?;
Ok(())
}
fn commit(&self, path: &Path, message: &str, author: &str) -> Result<String, NapError> {
LoreProcessRunner::run(["stage", "--scan", ".", "--non-interactive"], Some(path))?;
let stdout = LoreProcessRunner::run(
[
"revision",
"commit",
message,
"--identity",
author,
"--non-interactive",
],
Some(path),
)?;
let signature = stdout
.lines()
.find_map(|line| {
line.strip_prefix("Signature :")
.or_else(|| line.strip_prefix("Signature:"))
})
.map(|s| s.trim().to_string())
.unwrap_or_else(|| {
stdout
.lines()
.next()
.unwrap_or(&stdout)
.trim()
.strip_prefix("Created revision ")
.and_then(|s| s.split_whitespace().next())
.map(|s| s.to_string())
.unwrap_or_else(|| stdout.trim().to_string())
});
Ok(signature)
}
fn read_file_at_ref(
&self,
repo_path: &Path,
file_path: &str,
reference: Option<&str>,
) -> Result<String, NapError> {
let Some(reference) = reference else {
let full_path = repo_path.join(file_path);
return std::fs::read_to_string(&full_path).map_err(|e| {
NapError::VcsError(format!("failed to read {}: {}", full_path.display(), e))
});
};
let output_path = temp_lore_output_path("file-at-ref");
let output = output_path.to_string_lossy().into_owned();
LoreProcessRunner::run(
[
"file",
"write",
"--path",
file_path,
"--revision",
reference,
"--output",
&output,
"--non-interactive",
],
Some(repo_path),
)?;
let content = std::fs::read_to_string(&output_path).map_err(|e| {
NapError::VcsError(format!(
"failed to read {} at revision {} from {}: {}",
file_path,
reference,
output_path.display(),
e
))
})?;
let _ = std::fs::remove_file(&output_path);
Ok(content)
}
fn file_metadata_at_ref(
&self,
repo_path: &Path,
file_path: &str,
reference: &str,
) -> Result<Option<BTreeMap<String, String>>, NapError> {
let stdout = LoreProcessRunner::run(
[
"file",
"metadata",
"get",
file_path,
"--revision",
reference,
"--non-interactive",
],
Some(repo_path),
)?;
if stdout.trim().is_empty() || stdout.trim() == "null" {
return Ok(None);
}
parse_metadata_output(&stdout)
.map(Some)
.map_err(|e| NapError::VcsError(format!("failed to parse lore file metadata: {e}")))
}
fn read_provenance_blob(&self, repo_path: &Path, address: &str) -> Result<String, NapError> {
let output_path = temp_lore_output_path("provenance-blob");
let output = output_path.to_string_lossy().into_owned();
LoreProcessRunner::run(
[
"file",
"write",
"--address",
address,
"--output",
&output,
"--non-interactive",
],
Some(repo_path),
)?;
let content = std::fs::read_to_string(&output_path).map_err(|e| {
NapError::VcsError(format!(
"failed to read hydrated provenance blob {} from {}: {}",
address,
output_path.display(),
e
))
})?;
let _ = std::fs::remove_file(&output_path);
Ok(content)
}
fn log(
&self,
path: &Path,
_file: Option<&str>,
limit: usize,
) -> Result<Vec<CommitInfo>, NapError> {
let limit_str = limit.to_string();
let args = vec!["history", &limit_str, "--non-interactive"];
let stdout = LoreProcessRunner::run(&args, Some(path))?;
if stdout.trim().is_empty() {
return Ok(Vec::new());
}
let mut commits = Vec::new();
let mut current_signature = String::new();
let mut current_author = String::new();
let mut current_message = String::new();
let mut current_timestamp = String::new();
let mut current_parent: Option<String> = None;
let mut in_message = false;
for line in stdout.lines() {
let trimmed = line.trim();
if trimmed.starts_with("Signature :") || trimmed.starts_with("Signature:") {
if !current_signature.is_empty() {
commits.push(CommitInfo {
id: std::mem::take(&mut current_signature),
parent: current_parent.take(),
author: std::mem::take(&mut current_author),
message: std::mem::take(&mut current_message),
timestamp: std::mem::take(&mut current_timestamp),
});
}
current_signature = trimmed
.strip_prefix("Signature :")
.or_else(|| trimmed.strip_prefix("Signature:"))
.unwrap_or("")
.trim()
.to_string();
in_message = false;
} else if trimmed.starts_with("Date :") || trimmed.starts_with("Date:") {
current_timestamp = trimmed
.split_once(':')
.map(|(_, v)| v.trim().to_string())
.unwrap_or_default();
in_message = true;
} else if trimmed.starts_with("Creator :") || trimmed.starts_with("Creator:") {
current_author = trimmed
.split_once(':')
.map(|(_, v)| v.trim().to_string())
.unwrap_or_default();
in_message = false;
} else if trimmed.starts_with("Revision :")
|| trimmed.starts_with("Revision:")
|| trimmed.starts_with("Branch :")
|| trimmed.starts_with("Branch:")
|| trimmed.starts_with("Committer :")
|| trimmed.starts_with("Committer:")
{
in_message = false;
} else if in_message {
if trimmed.is_empty() || trimmed == "Commit succeeded" {
in_message = false;
} else {
if !current_message.is_empty() {
current_message.push('\n');
}
current_message.push_str(trimmed);
}
}
}
if !current_signature.is_empty() {
commits.push(CommitInfo {
id: current_signature,
parent: current_parent,
author: current_author,
message: current_message,
timestamp: current_timestamp,
});
}
Ok(commits)
}
fn create_branch(&self, path: &Path, name: &str) -> Result<(), NapError> {
LoreProcessRunner::run(["branch", "create", name, "--non-interactive"], Some(path))?;
Ok(())
}
fn switch_branch(&self, path: &Path, name: &str) -> Result<(), NapError> {
LoreProcessRunner::run(["branch", "switch", name, "--non-interactive"], Some(path))?;
Ok(())
}
fn current_branch(&self, path: &Path) -> Result<String, NapError> {
let stdout = LoreProcessRunner::run(["branch", "show", "--non-interactive"], Some(path))?;
Ok(stdout.trim().to_string())
}
fn list_branches(&self, path: &Path) -> Result<Vec<String>, NapError> {
let stdout = LoreProcessRunner::run(["branch", "list", "--non-interactive"], Some(path))?;
if stdout.is_empty() {
return Ok(Vec::new());
}
let mut branches = Vec::new();
let mut in_local = false;
for line in stdout.lines() {
let trimmed = line.trim();
if trimmed.starts_with("Local branches") {
in_local = true;
continue;
}
if trimmed.starts_with("Remote branches") {
in_local = false;
continue;
}
if in_local && !trimmed.is_empty() {
let name = trimmed.strip_prefix("* ").unwrap_or(trimmed);
branches.push(name.to_string());
}
}
Ok(branches)
}
fn head_hash(&self, path: &Path) -> Result<String, NapError> {
let stdout = LoreProcessRunner::run(["history", "1", "--non-interactive"], Some(path))?;
if stdout.trim().is_empty() {
return Err(NapError::VcsError(
"no commits in lore workspace".to_string(),
));
}
stdout
.lines()
.find_map(|line| {
line.trim()
.strip_prefix("Signature :")
.or_else(|| line.trim().strip_prefix("Signature:"))
})
.map(|s| s.trim().to_string())
.ok_or_else(|| {
NapError::VcsError(format!(
"failed to parse signature from lore history: {stdout}"
))
})
}
fn revert(&self, path: &Path, commit_hash: &str) -> Result<String, NapError> {
let stdout = LoreProcessRunner::run(
["revision", "revert", commit_hash, "--non-interactive"],
Some(path),
)?;
let signature = stdout
.trim()
.strip_prefix("Created revert revision ")
.unwrap_or(stdout.trim());
Ok(signature.to_string())
}
fn resolve_branch_head(&self, path: &Path, branch: &str) -> Result<String, NapError> {
let stdout = LoreProcessRunner::run(
["history", "1", "--branch", branch, "--non-interactive"],
Some(path),
)?;
if stdout.trim().is_empty() {
return Err(NapError::VcsError(format!(
"no commits found on branch '{branch}'"
)));
}
stdout
.lines()
.find_map(|line| {
line.trim()
.strip_prefix("Signature :")
.or_else(|| line.trim().strip_prefix("Signature:"))
})
.map(|s| s.trim().to_string())
.ok_or_else(|| {
NapError::VcsError(format!(
"failed to parse signature from lore history on branch '{branch}': {stdout}"
))
})
}
fn add_remote(&self, path: &Path, name: &str, url: &str) -> Result<(), NapError> {
let remotes_path = path.join(".lore").join("remotes.toml");
let mut map: std::collections::BTreeMap<String, String> = if remotes_path.exists() {
let content = std::fs::read_to_string(&remotes_path).unwrap_or_default();
toml::from_str(&content).unwrap_or_default()
} else {
std::collections::BTreeMap::new()
};
map.insert(name.to_string(), url.to_string());
if let Some(parent) = remotes_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| NapError::VcsError(e.to_string()))?;
}
let content = toml::to_string(&map).map_err(|e| NapError::VcsError(e.to_string()))?;
std::fs::write(&remotes_path, content).map_err(|e| NapError::VcsError(e.to_string()))?;
Ok(())
}
fn remove_remote(&self, path: &Path, name: &str) -> Result<(), NapError> {
let remotes_path = path.join(".lore").join("remotes.toml");
if !remotes_path.exists() {
return Ok(());
}
let content = std::fs::read_to_string(&remotes_path).unwrap_or_default();
let mut map: std::collections::BTreeMap<String, String> =
toml::from_str(&content).unwrap_or_default();
map.remove(name);
let new_content = toml::to_string(&map).map_err(|e| NapError::VcsError(e.to_string()))?;
std::fs::write(&remotes_path, new_content)
.map_err(|e| NapError::VcsError(e.to_string()))?;
Ok(())
}
fn list_remotes(&self, path: &Path) -> Result<Vec<(String, String)>, NapError> {
let remotes_path = path.join(".lore").join("remotes.toml");
if !remotes_path.exists() {
return Ok(Vec::new());
}
let content = std::fs::read_to_string(&remotes_path).unwrap_or_default();
let map: std::collections::BTreeMap<String, String> =
toml::from_str(&content).unwrap_or_default();
Ok(map.into_iter().collect())
}
fn push(
&self,
path: &Path,
_remote: Option<&str>,
branch: Option<&str>,
) -> Result<(), NapError> {
let branch_name = match branch {
Some(b) => b.to_string(),
None => self
.current_branch(path)
.unwrap_or_else(|_| "main".to_string()),
};
let args = vec![
"branch",
"push",
&branch_name,
"--fast-forward-merge",
"--non-interactive",
];
LoreProcessRunner::run(&args, Some(path))?;
Ok(())
}
fn pull(
&self,
path: &Path,
_remote: Option<&str>,
_branch: Option<&str>,
) -> Result<(), NapError> {
let args = vec!["sync", "--non-interactive", "--reset"];
LoreProcessRunner::run(&args, Some(path))?;
Ok(())
}
}
#[cfg(all(test, feature = "lore-integration"))]
mod tests {
use super::*;
#[test]
fn test_binary_default() {
assert_eq!(LoreProcessRunner::binary(), "lore");
}
#[test]
fn test_binary_from_env() {
temp_env::with_var("NAPLORE_CLI", Some("/custom/lore"), || {
assert_eq!(LoreProcessRunner::binary(), "/custom/lore");
});
}
#[test]
fn test_run_captures_stdout() {
temp_env::with_var("NAPLORE_CLI", Some("lore-nonexistent-binary-12345"), || {
let result = LoreProcessRunner::run(["--version"], None);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("lore-nonexistent-binary-12345"),
"error: {}",
err
);
});
}
#[test]
fn test_new_and_from_env() {
let backend = LoreBackend::new("lore://myhost:8700", "test-workspace");
assert_eq!(backend.remote_url, "lore://myhost:8700");
assert_eq!(backend.workspace_id, "test-workspace");
temp_env::with_vars(
vec![
("NAP_LORE_URL_BASE", Some("lore://custom:9999")),
("NAP_WORKSPACE_ID", Some("custom-ws")),
],
|| {
let from_env = LoreBackend::from_env();
assert_eq!(from_env.remote_url, "lore://custom:9999");
assert_eq!(from_env.workspace_id, "custom-ws");
},
);
}
#[test]
fn test_from_env_default_without_env_vars() {
let temp_dir = tempfile::TempDir::new().unwrap();
let nap_dir_str = temp_dir.path().to_str().unwrap();
temp_env::with_vars(
vec![
("NAP_LORE_URL_BASE", None::<&str>),
("NAP_WORKSPACE_ID", None::<&str>),
("NAP_DIR", Some(nap_dir_str)),
],
|| {
let backend = LoreBackend::from_env();
assert_eq!(backend.remote_url, "lore://localhost:41337");
assert_eq!(backend.workspace_id, "default");
},
);
}
#[test]
fn test_from_env_env_var_override() {
let temp_dir = tempfile::TempDir::new().unwrap();
let nap_dir_str = temp_dir.path().to_str().unwrap();
temp_env::with_vars(
vec![
("NAP_LORE_URL_BASE", Some("lore://override:1234")),
("NAP_WORKSPACE_ID", Some("override-ws")),
("NAP_DIR", Some(nap_dir_str)),
],
|| {
let backend = LoreBackend::from_env();
assert_eq!(backend.remote_url, "lore://override:1234");
assert_eq!(backend.workspace_id, "override-ws");
},
);
}
#[test]
fn test_from_env_partial_env_override() {
let temp_dir = tempfile::TempDir::new().unwrap();
let nap_dir_str = temp_dir.path().to_str().unwrap();
temp_env::with_vars(
vec![
("NAP_LORE_URL_BASE", Some("lore://partial:5678")),
("NAP_WORKSPACE_ID", None::<&str>),
("NAP_DIR", Some(nap_dir_str)),
],
|| {
let backend = LoreBackend::from_env();
assert_eq!(backend.remote_url, "lore://partial:5678");
assert_eq!(backend.workspace_id, "default");
},
);
}
#[test]
fn test_from_env_provider_config() {
let temp_dir = tempfile::TempDir::new().unwrap();
let provider_config = temp_dir.path().join("provider.toml");
std::fs::write(
&provider_config,
r#"
provider_type = "remote"
remote_url = "lore://provider:9999"
workspace_id = "provider-ws"
"#,
)
.unwrap();
let nap_dir_str = temp_dir.path().to_str().unwrap();
temp_env::with_vars(
vec![
("NAP_LORE_URL_BASE", None::<&str>),
("NAP_WORKSPACE_ID", None::<&str>),
("NAP_DIR", Some(nap_dir_str)),
],
|| {
let backend = LoreBackend::from_env();
assert_eq!(backend.remote_url, "lore://provider:9999");
assert_eq!(backend.workspace_id, "provider-ws");
},
);
}
#[test]
fn test_from_env_nap_dir_with_tilde() {
let _home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
let temp_dir = tempfile::TempDir::new().unwrap();
let nap_dir_str = temp_dir.path().to_str().unwrap();
temp_env::with_vars(
vec![
("NAP_LORE_URL_BASE", None::<&str>),
("NAP_WORKSPACE_ID", None::<&str>),
("NAP_DIR", Some(nap_dir_str)),
],
|| {
let backend = LoreBackend::from_env();
assert_eq!(backend.remote_url, "lore://localhost:41337");
assert_eq!(backend.workspace_id, "default");
},
);
}
#[test]
fn test_from_env_local_provider_config() {
let temp_dir = tempfile::TempDir::new().unwrap();
let provider_config = temp_dir.path().join("provider.toml");
std::fs::write(
&provider_config,
r#"
provider_type = "local"
"#,
)
.unwrap();
let nap_dir_str = temp_dir.path().to_str().unwrap();
temp_env::with_vars(
vec![
("NAP_LORE_URL_BASE", None::<&str>),
("NAP_WORKSPACE_ID", None::<&str>),
("NAP_DIR", Some(nap_dir_str)),
],
|| {
let backend = LoreBackend::from_env();
assert_eq!(backend.remote_url, "lore://localhost:41337");
assert_eq!(backend.workspace_id, "default");
},
);
}
#[test]
fn test_from_env_portals_cloud_provider_config() {
let temp_dir = tempfile::TempDir::new().unwrap();
let provider_config = temp_dir.path().join("provider.toml");
std::fs::write(
&provider_config,
r#"
provider_type = "portals-cloud"
workspace_id = "cloud-ws"
"#,
)
.unwrap();
let nap_dir_str = temp_dir.path().to_str().unwrap();
temp_env::with_vars(
vec![
("NAP_LORE_URL_BASE", None::<&str>),
("NAP_WORKSPACE_ID", None::<&str>),
("NAP_DIR", Some(nap_dir_str)),
],
|| {
let backend = LoreBackend::from_env();
assert_eq!(backend.remote_url, PORTALS_CLOUD_URL);
assert_eq!(backend.workspace_id, "cloud-ws");
},
);
}
#[test]
fn test_from_env_portals_cloud_default_workspace() {
let temp_dir = tempfile::TempDir::new().unwrap();
let provider_config = temp_dir.path().join("provider.toml");
std::fs::write(
&provider_config,
r#"
provider_type = "portals-cloud"
"#,
)
.unwrap();
let nap_dir_str = temp_dir.path().to_str().unwrap();
temp_env::with_vars(
vec![
("NAP_LORE_URL_BASE", None::<&str>),
("NAP_WORKSPACE_ID", None::<&str>),
("NAP_DIR", Some(nap_dir_str)),
],
|| {
let backend = LoreBackend::from_env();
assert_eq!(backend.remote_url, PORTALS_CLOUD_URL);
assert_eq!(backend.workspace_id, "default");
},
);
}
#[test]
fn test_from_env_unknown_provider_type() {
let temp_dir = tempfile::TempDir::new().unwrap();
let provider_config = temp_dir.path().join("provider.toml");
std::fs::write(
&provider_config,
r#"
provider_type = "unknown-provider"
"#,
)
.unwrap();
let nap_dir_str = temp_dir.path().to_str().unwrap();
temp_env::with_vars(
vec![
("NAP_LORE_URL_BASE", None::<&str>),
("NAP_WORKSPACE_ID", None::<&str>),
("NAP_DIR", Some(nap_dir_str)),
],
|| {
let backend = LoreBackend::from_env();
assert_eq!(backend.remote_url, "lore://localhost:41337");
assert_eq!(backend.workspace_id, "default");
},
);
}
#[test]
fn test_repo_url_joining() {
let backend = LoreBackend::new("lore://localhost:8700", "ws");
assert_eq!(backend.repo_url("my-repo"), "lore://localhost:8700/my-repo");
let backend2 = LoreBackend::new("lore://host:8700/", "ws");
assert_eq!(backend2.repo_url("foo"), "lore://host:8700/foo");
}
#[test]
fn test_list_branches_empty_json() {
}
#[test]
fn test_commit_parses_signature_from_stdout() {
let sample = "Created revision a1b2c3d4 (#42)";
let signature = sample
.strip_prefix("Created revision ")
.and_then(|s| s.split_whitespace().next())
.unwrap_or(sample);
assert_eq!(signature, "a1b2c3d4");
}
#[test]
fn test_commit_info_from_lore_revision() {
let info = CommitInfo::from_lore_revision(
"sig123",
Some("sig122"),
"alice",
"feat: add manifest",
"2026-06-30T12:00:00Z",
);
assert_eq!(info.id, "sig123");
assert_eq!(info.parent.as_deref(), Some("sig122"));
assert_eq!(info.author, "alice");
assert_eq!(info.message, "feat: add manifest");
assert_eq!(info.timestamp, "2026-06-30T12:00:00Z");
}
#[test]
fn test_commit_info_default_timestamp() {
let info = CommitInfo::from_lore_revision("sig", None, "bob", "msg", "");
assert!(
info.timestamp.contains('T') || info.timestamp.contains('Z'),
"expected RFC 3339 timestamp, got: {}",
info.timestamp
);
}
}