use std::path::Path;
use std::process::Command;
use crate::error::NapError;
use crate::vcs::{CommitInfo, VcsBackend};
struct LoreProcessRunner;
impl LoreProcessRunner {
fn binary() -> String {
std::env::var("NAPLORE_CLI").unwrap_or_else(|_| "lore".to_string())
}
fn run<I, S>(args: I, cwd: Option<&Path>) -> Result<String, NapError>
where
I: IntoIterator<Item = S>,
S: AsRef<std::ffi::OsStr>,
{
let bin = Self::binary();
let mut cmd = Command::new(&bin);
cmd.args(args);
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
let output = cmd.output().map_err(|e| {
NapError::VcsError(format!(
"failed to execute `{}`: {}. Is `{}` installed and on $PATH?",
bin, e, bin
))
})?;
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 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)
}
}
#[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 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 {
let base = std::env::var("NAP_LORE_URL_BASE")
.unwrap_or_else(|_| "lore://localhost:8700".to_string());
let workspace_id =
std::env::var("NAP_WORKSPACE_ID").unwrap_or_else(|_| "default".to_string());
Self::new(&base, &workspace_id)
}
fn repo_url(&self, repo_id: &str) -> String {
format!("{}/{}", self.remote_url.trim_end_matches('/'), repo_id)
}
}
impl VcsBackend for LoreBackend {
fn init(&self, path: &Path) -> Result<(), NapError> {
let repo_id = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("nap-repo");
let url = self.repo_url(repo_id);
LoreProcessRunner::run(
[
"repository",
"create",
&url,
"--id",
&self.workspace_id,
"--non-interactive",
],
None,
)
.map_err(|e| {
NapError::VcsError(format!("failed to create lore repository '{}': {}", url, e))
})?;
LoreProcessRunner::run(
[
"clone",
&url,
path.to_str().unwrap_or("."),
"--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",
message,
"--identity",
author,
"--non-interactive",
],
Some(path),
)?;
let signature = 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 mut args = vec!["file", "cat", file_path, "--non-interactive"];
if let Some(ref_str) = reference {
args.push("--revision");
args.push(ref_str);
}
LoreProcessRunner::run(&args, Some(repo_path))
}
fn log(
&self,
path: &Path,
file: Option<&str>,
limit: usize,
) -> Result<Vec<CommitInfo>, NapError> {
let limit_str = limit.to_string();
let mut args = vec![
"log",
"--limit",
&limit_str,
"--format",
"json",
"--non-interactive",
];
if let Some(f) = file {
args.push("--path");
args.push(f);
}
let stdout = LoreProcessRunner::run(&args, Some(path))?;
if stdout.is_empty() || stdout == "[]" || stdout == "null" {
return Ok(Vec::new());
}
#[derive(serde::Deserialize)]
struct LoreRevision {
signature: String,
#[allow(dead_code)]
number: u64,
message: String,
author: String,
timestamp: Option<String>,
parent_signature: Option<String>,
}
let revs: Vec<LoreRevision> = serde_json::from_str(&stdout).map_err(|e| {
NapError::VcsError(format!(
"failed to parse lore log output as JSON: {}. Raw output: {}",
e, stdout
))
})?;
Ok(revs
.into_iter()
.map(|r| {
CommitInfo::from_lore_revision(
&r.signature,
r.parent_signature.as_deref(),
&r.author,
&r.message,
r.timestamp.as_deref().unwrap_or(""),
)
})
.collect())
}
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", "--format", "json", "--non-interactive"],
Some(path),
)?;
if stdout.is_empty() || stdout == "[]" || stdout == "null" {
return Ok(Vec::new());
}
let branches: Vec<String> = serde_json::from_str(&stdout).map_err(|e| {
NapError::VcsError(format!(
"failed to parse lore branch list JSON: {}. Raw: {}",
e, stdout
))
})?;
Ok(branches)
}
fn create_tag(&self, path: &Path, name: &str) -> Result<(), NapError> {
let current = LoreProcessRunner::run(
[
"file",
"metadata",
"get",
"--key",
"nap.labels",
"--format",
"json",
"--non-interactive",
],
Some(path),
)
.unwrap_or_else(|_| "[]".to_string());
let mut labels: Vec<String> = serde_json::from_str(¤t).unwrap_or_default();
if !labels.contains(&name.to_string()) {
labels.push(name.to_string());
}
let labels_json = serde_json::to_string(&labels)
.map_err(|e| NapError::VcsError(format!("failed to serialise label list: {}", e)))?;
LoreProcessRunner::run(
[
"file",
"metadata",
"set",
"--key",
"nap.labels",
"--value",
&labels_json,
"--non-interactive",
],
Some(path),
)?;
Ok(())
}
fn list_tags(&self, path: &Path) -> Result<Vec<String>, NapError> {
let stdout = LoreProcessRunner::run(
[
"file",
"metadata",
"get",
"--key",
"nap.labels",
"--format",
"json",
"--non-interactive",
],
Some(path),
)?;
if stdout.is_empty() || stdout == "[]" || stdout == "null" {
return Ok(Vec::new());
}
let labels: Vec<String> = serde_json::from_str(&stdout).map_err(|e| {
NapError::VcsError(format!(
"failed to parse lore labels JSON: {}. Raw: {}",
e, stdout
))
})?;
Ok(labels)
}
fn head_hash(&self, path: &Path) -> Result<String, NapError> {
let stdout = LoreProcessRunner::run(
[
"log",
"--limit",
"1",
"--format",
"json",
"--non-interactive",
],
Some(path),
)?;
if stdout.is_empty() || stdout == "[]" || stdout == "null" {
return Err(NapError::VcsError(
"no commits in lore workspace".to_string(),
));
}
#[derive(serde::Deserialize)]
struct HeadRev {
signature: String,
}
let revs: Vec<HeadRev> = serde_json::from_str(&stdout).map_err(|e| {
NapError::VcsError(format!(
"failed to parse lore log JSON for head_hash: {}. Raw: {}",
e, stdout
))
})?;
revs.into_iter()
.next()
.map(|r| r.signature)
.ok_or_else(|| NapError::VcsError("empty revision list".to_string()))
}
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 add_remote(&self, path: &Path, name: &str, url: &str) -> Result<(), NapError> {
LoreProcessRunner::run(
[
"repository",
"add",
url,
"--alias",
name,
"--non-interactive",
],
Some(path),
)?;
Ok(())
}
fn remove_remote(&self, path: &Path, name: &str) -> Result<(), NapError> {
LoreProcessRunner::run(
["repository", "remove", "--alias", name, "--non-interactive"],
Some(path),
)?;
Ok(())
}
fn list_remotes(&self, path: &Path) -> Result<Vec<(String, String)>, NapError> {
let stdout = LoreProcessRunner::run(
[
"repository",
"list",
"--format",
"json",
"--non-interactive",
],
Some(path),
)?;
if stdout.is_empty() || stdout == "[]" || stdout == "null" {
return Ok(Vec::new());
}
#[derive(serde::Deserialize)]
struct RemoteEntry {
#[allow(dead_code)]
name: String,
#[allow(dead_code)]
url: String,
}
let entries: Vec<RemoteEntry> = serde_json::from_str(&stdout).map_err(|e| {
NapError::VcsError(format!(
"failed to parse lore repository list JSON: {}. Raw: {}",
e, stdout
))
})?;
let pairs: Vec<(String, String)> = entries.into_iter().map(|e| (e.name, e.url)).collect();
Ok(pairs)
}
fn push(
&self,
path: &Path,
remote: Option<&str>,
_branch: Option<&str>,
) -> Result<(), NapError> {
let mut args = vec!["revision", "publish", "--non-interactive"];
if let Some(r) = remote {
args.push("--remote");
args.push(r);
}
LoreProcessRunner::run(&args, Some(path))?;
Ok(())
}
fn pull(
&self,
path: &Path,
remote: Option<&str>,
_branch: Option<&str>,
) -> Result<(), NapError> {
let mut args = vec!["update", "--non-interactive"];
if let Some(r) = remote {
args.push("--remote");
args.push(r);
}
LoreProcessRunner::run(&args, Some(path))?;
Ok(())
}
}
#[cfg(test)]
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_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
);
}
}