Skip to main content

nap_core/
vcs_lore.rs

1//! Lore VCS backend implementation.
2//!
3//! [`LoreBackend`] implements [`VcsBackend`] by shelling out to the `lore`
4//! CLI. All processes are run
5//! non-interactively with structured JSON output where possible.
6//!
7//! ## CLI command mapping
8//!
9//! | `VcsBackend` method          | `lore` equivalent                                        |
10//! |------------------------------|----------------------------------------------------------|
11//! | `init`                       | `lore repository create` + `lore clone`                  |
12//! | `commit`                     | `lore stage --scan` + `lore revision commit`             |
13//! | `read_file_at_ref`           | `lore file cat <path> --revision <ref>`                  |
14//! | `log`                        | `lore log --format json`                                 |
15//! | `create_branch`              | `lore branch create <name>`                              |
16//! | `switch_branch`              | `lore branch switch <name>`                              |
17//! | `current_branch`             | `lore branch show`                                       |
18//! | `head_hash`                  | `lore log --limit 1 --format json`                       |
19//! | `revert`                     | `lore revision revert <hash>`                            |
20//! | `list_branches`              | `lore branch list`                                       |
21//! | `add_remote`                 | `lore repository add <url>`                              |
22//! | `remove_remote`              | `lore repository remove <url>`                           |
23//! | `list_remotes`               | `lore repository list`                                   |
24//! | `push`                       | `lore branch push`                                       |
25//! | `pull`                       | `lore sync`                                              |
26//!
27//! ## Error translation
28//!
29//! Known `lore` exit codes are mapped to structured [`NapError`] variants.
30//! Unknown failures capture the full CLI stderr for debugging.  No error
31//! is ever silently swallowed.
32
33use std::path::{Path, PathBuf};
34use std::process::Command;
35use std::time::Instant;
36
37use crate::error::NapError;
38use crate::vcs::{CommitInfo, VcsBackend};
39
40/// Minimal TOML structure for parsing provider.toml
41#[derive(serde::Deserialize)]
42struct ProviderConfigToml {
43    provider_type: String,
44    remote_url: Option<String>,
45    workspace_id: Option<String>,
46}
47
48/// Hardcoded Portals Cloud URL (can be overridden by NAP_LORE_URL_BASE env var)
49const PORTALS_CLOUD_URL: &str = "lore://cloud.portals.sh:41337";
50
51// ---------------------------------------------------------------------------
52// LoreProcessRunner
53// ---------------------------------------------------------------------------
54
55/// A thin runner that executes `lore(1)` CLI commands.
56///
57/// All invocations inject:
58/// - `--non-interactive` so the CLI never blocks on input.
59/// - `--format json` when the corresponding method supports structured output.
60///
61/// ## Design
62///
63/// This struct exists as a single point of process-control policy: it
64/// is the **only** code in the crate that calls `std::process::Command`.
65/// Every other module uses [`VcsBackend`] or [`RepoService`] and never
66/// touches the `lore` binary directly.
67pub struct LoreProcessRunner;
68
69impl LoreProcessRunner {
70    /// Path to the `lore` binary.  Override via `NAPLORE_CLI` env var, or
71    /// default to `lore` (picked up from `$PATH`).
72    pub fn binary() -> String {
73        std::env::var("NAPLORE_CLI").unwrap_or_else(|_| "lore".to_string())
74    }
75
76    /// Run a `lore` subcommand and return stdout on success.
77    ///
78    /// `cwd` sets the working directory (the Lore workspace directory).
79    pub fn run<I, S>(args: I, cwd: Option<&Path>) -> Result<String, NapError>
80    where
81        I: IntoIterator<Item = S>,
82        S: AsRef<std::ffi::OsStr>,
83    {
84        let args_vec: Vec<String> = args
85            .into_iter()
86            .map(|s| s.as_ref().to_string_lossy().into_owned())
87            .collect();
88        let bin = Self::binary();
89        let mut cmd = Command::new(&bin);
90        cmd.args(&args_vec);
91
92        if let Some(dir) = cwd {
93            cmd.current_dir(dir);
94        }
95
96        let start = Instant::now();
97        // Safety: we capture output — no interactive TTY needed.
98        let output = cmd.output().map_err(|e| {
99            NapError::VcsError(format!(
100                "failed to execute `{}`: {}. Is `{}` installed and on $PATH?",
101                bin, e, bin
102            ))
103        })?;
104        let duration = start.elapsed();
105        if duration > std::time::Duration::from_secs(5) {
106            tracing::warn!(
107                duration_ms = duration.as_millis(),
108                command = format!("{} {:?}", bin, args_vec),
109                "lore command took > 5s — check Lore server health"
110            );
111        }
112
113        if output.status.success() {
114            let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
115            return Ok(stdout);
116        }
117
118        // ── Error translation ────────────────────────────────────────
119        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
120        let exit_code = output.status.code().unwrap_or(-1);
121
122        // We categorise known Lore exit codes into NapError variants.
123        // For v0 this is best-effort; the list will grow with production
124        // experience.
125        let nap_err = match exit_code {
126            1 => {
127                // Generic error — check for known patterns in stderr.
128                if stderr.contains("not a lore workspace")
129                    || stderr.contains("not an initialised lore workspace")
130                {
131                    NapError::VcsError(format!(
132                        "not a lore workspace at {:?}",
133                        cwd.unwrap_or(Path::new("."))
134                    ))
135                } else if stderr.contains("not found") {
136                    NapError::VcsError(format!("path not found in lore workspace: {}", stderr))
137                } else {
138                    NapError::VcsError(format!(
139                        "lore CLI exited with code {}: {}",
140                        exit_code, stderr
141                    ))
142                }
143            }
144            64..=126 => {
145                // Usage / config errors.
146                NapError::VcsError(format!(
147                    "lore CLI configuration error ({}): {}",
148                    exit_code, stderr
149                ))
150            }
151            _ => NapError::VcsError(format!(
152                "lore CLI exited with code {}: {}",
153                exit_code, stderr
154            )),
155        };
156
157        Err(nap_err)
158    }
159}
160
161// ---------------------------------------------------------------------------
162// LoreBackend
163// ---------------------------------------------------------------------------
164
165/// A [`VcsBackend`] implementation backed by the Lore VCS CLI (`lore(1)`).
166///
167/// `LoreBackend` requires a remote `lore://` URL and a workspace identity
168/// so that it can call `lore repository create` / `lore clone` during init.
169///
170/// Use [`LoreBackend::new()`] for the default configuration
171/// (reads env-var overrides for the server URL, or falls back to a
172/// local-dev default).
173#[derive(Debug, Clone)]
174pub struct LoreBackend {
175    /// The `lore://` remote URL for the repository.
176    remote_url: String,
177    /// Workspace identifier (multi-tenancy scope).
178    workspace_id: String,
179}
180
181impl LoreBackend {
182    /// Create a new Lore backend.
183    ///
184    /// `remote_url` should be a `lore://host/repository` URL.
185    /// `workspace_id` scopes the repository to a multi-tenant workspace.
186    pub fn new(remote_url: &str, workspace_id: &str) -> Self {
187        Self {
188            remote_url: remote_url.to_string(),
189            workspace_id: workspace_id.to_string(),
190        }
191    }
192
193    pub fn remote_url(&self) -> &str {
194        &self.remote_url
195    }
196
197    /// Clone a remote Lore repository to a local path.
198    ///
199    /// Equivalent to `lore clone <url> <dest>`.  Does NOT require an
200    /// existing `LoreBackend` instance — use this when you just want
201    /// to clone and don't need a full backend.
202    pub fn clone_repo(url: &str, dest: &Path) -> Result<(), NapError> {
203        LoreProcessRunner::run(
204            [
205                "clone",
206                url,
207                dest.to_str().unwrap_or("."),
208                "--non-interactive",
209            ],
210            None,
211        )?;
212        Ok(())
213    }
214
215    /// Convenience constructor that reads configuration from environment
216    /// variables with sensible local-development defaults.
217    ///
218    /// Precedence: env vars > provider config > defaults
219    ///
220    /// | Env var               | Default                   |
221    /// |-----------------------|---------------------------|
222    /// | `NAP_LORE_URL_BASE`   | `lore://localhost:41337`  |
223    /// | `NAP_WORKSPACE_ID`    | `default`                 |
224    ///
225    /// Note: For new code, prefer using the RepositoryApi with Provider architecture
226    /// instead of this legacy environment-based constructor.
227    pub fn from_env() -> Self {
228        // Ensure the Lore server is running
229        if let Ok(nap_dir) = std::env::var("NAP_DIR") {
230            let manager = crate::server::manager::ServerManager::new(Path::new(&nap_dir));
231            let _ = tokio::runtime::Handle::try_current().map(|handle| {
232                handle.block_on(async {
233                    let _ = manager.ensure_running().await;
234                });
235            });
236        }
237
238        // Priority 1: Environment variables (for testing/override)
239        let url_from_env = std::env::var("NAP_LORE_URL_BASE").ok();
240        let workspace_from_env = std::env::var("NAP_WORKSPACE_ID").ok();
241
242        if url_from_env.is_some() || workspace_from_env.is_some() {
243            let base = url_from_env.unwrap_or_else(|| "lore://localhost:41337".to_string());
244            let workspace_id = workspace_from_env.unwrap_or_else(|| "default".to_string());
245            tracing::debug!(
246                url_base = %base,
247                workspace_id = %workspace_id,
248                "LoreBackend::from_env using environment variables (override)"
249            );
250            return Self {
251                remote_url: base,
252                workspace_id,
253            };
254        }
255
256        // Priority 2: Provider configuration
257        let nap_dir = if let Ok(nap_dir_str) = std::env::var("NAP_DIR") {
258            // Expand ~ in NAP_DIR if present (same logic as nap-cli expand_path)
259            let path = PathBuf::from(&nap_dir_str);
260            if let Some(s) = path.to_str() {
261                if let Some(stripped) = s.strip_prefix('~') {
262                    let home = std::env::var("HOME")
263                        .or_else(|_| std::env::var("USERPROFILE"))
264                        .unwrap_or_else(|_| ".".to_string());
265                    PathBuf::from(home).join(stripped.trim_start_matches('/'))
266                } else {
267                    path
268                }
269            } else {
270                path
271            }
272        } else {
273            // Default to ~/.nap if NAP_DIR is not set
274            let home = std::env::var("HOME")
275                .or_else(|_| std::env::var("USERPROFILE"))
276                .unwrap_or_else(|_| ".".to_string());
277            PathBuf::from(home).join(".nap")
278        };
279
280        let provider_config_path = nap_dir.join("provider.toml");
281        if provider_config_path.exists()
282            && let Ok(config_content) = std::fs::read_to_string(&provider_config_path)
283            && let Ok(config) = toml::from_str::<ProviderConfigToml>(&config_content)
284        {
285            match config.provider_type.as_str() {
286                "local" => {
287                    // Local provider uses localhost defaults
288                    tracing::debug!(
289                        url_base = "lore://localhost:41337",
290                        workspace_id = "default",
291                        "LoreBackend::from_env using local provider configuration"
292                    );
293                    return Self {
294                        remote_url: "lore://localhost:41337".to_string(),
295                        workspace_id: "default".to_string(),
296                    };
297                }
298                "remote" => {
299                    // Remote provider uses configured URL and workspace
300                    if let (Some(url), Some(workspace)) = (config.remote_url, config.workspace_id) {
301                        tracing::debug!(
302                            url_base = %url,
303                            workspace_id = %workspace,
304                            "LoreBackend::from_env using remote provider configuration"
305                        );
306                        return Self {
307                            remote_url: url,
308                            workspace_id: workspace,
309                        };
310                    }
311                }
312                "portals-cloud" => {
313                    // Portals Cloud uses hardcoded URL (env vars already checked above)
314                    let workspace_id = config.workspace_id.unwrap_or_else(|| "default".to_string());
315                    tracing::debug!(
316                        url_base = %PORTALS_CLOUD_URL,
317                        workspace_id = %workspace_id,
318                        "LoreBackend::from_env using portals-cloud provider configuration"
319                    );
320                    return Self {
321                        remote_url: PORTALS_CLOUD_URL.to_string(),
322                        workspace_id,
323                    };
324                }
325                _ => {
326                    tracing::debug!(
327                        provider_type = %config.provider_type,
328                        "Unknown provider type, falling back to defaults"
329                    );
330                }
331            }
332        }
333
334        // Priority 3: Defaults
335        let base = "lore://localhost:41337".to_string();
336        let workspace_id = "default".to_string();
337        tracing::debug!(
338            url_base = %base,
339            workspace_id = %workspace_id,
340            "LoreBackend::from_env using defaults"
341        );
342        Self {
343            remote_url: base,
344            workspace_id,
345        }
346    }
347
348    /// Create LoreBackend from provider configuration
349    ///
350    /// This is the preferred constructor for new code using the Provider architecture.
351    pub fn from_provider(url_base: &str, workspace_id: &str) -> Self {
352        tracing::debug!(
353            url_base = %url_base,
354            workspace_id = %workspace_id,
355            "Creating LoreBackend from provider configuration"
356        );
357
358        Self {
359            remote_url: url_base.to_string(),
360            workspace_id: workspace_id.to_string(),
361        }
362    }
363
364    /// Build a `lore::` remote URL for a given repository ID.
365    fn repo_url(&self, repo_id: &str) -> String {
366        format!("{}/{}", self.remote_url.trim_end_matches('/'), repo_id)
367    }
368}
369
370impl VcsBackend for LoreBackend {
371    /// Get the remote URL base for constructing repository URLs.
372    fn remote_url_base(&self) -> Result<String, NapError> {
373        Ok(self.remote_url.clone())
374    }
375
376    // ── init ─────────────────────────────────────────────────────────
377    fn init(&self, path: &Path) -> Result<(), NapError> {
378        // For Lore, "init" means:
379        //   1. `lore repository create <repo_url> --id <ws> --repository <server_path>`
380        //   2. `lore clone <repo_url> <local_path>`
381        //
382        // We derive a repo id from the leaf directory of `path`.
383        // The server-side data is stored at `<parent>/.lore-server/<repo_id>`
384        // to avoid collision with the clone destination.
385
386        let repo_id = path
387            .file_name()
388            .and_then(|n| n.to_str())
389            .unwrap_or("nap-repo");
390
391        let url = self.repo_url(repo_id);
392        let path_str = path.to_str().unwrap_or(".");
393
394        // Server-side storage lives alongside the repo, not inside it.
395        let server_path = path
396            .parent()
397            .unwrap_or(path)
398            .join(".lore-server")
399            .join(repo_id);
400
401        // Step 1: Create the remote repository.
402        LoreProcessRunner::run(
403            [
404                "repository",
405                "create",
406                &url,
407                "--id",
408                &self.workspace_id,
409                "--repository",
410                server_path.to_str().unwrap_or("."),
411                "--non-interactive",
412            ],
413            None,
414        )
415        .map_err(|e| {
416            NapError::VcsError(format!("failed to create lore repository '{}': {}", url, e))
417        })?;
418
419        // Step 2: Clone it locally.
420        LoreProcessRunner::run(["clone", &url, path_str, "--non-interactive"], None).map_err(
421            |e| {
422                NapError::VcsError(format!(
423                    "failed to clone lore repository to {:?}: {}",
424                    path, e
425                ))
426            },
427        )?;
428
429        Ok(())
430    }
431
432    // ── commit ───────────────────────────────────────────────────────
433    fn commit(&self, path: &Path, message: &str, author: &str) -> Result<String, NapError> {
434        // Lore requires an explicit stage step.
435        // Stage 1: Discover and stage all changes.
436        LoreProcessRunner::run(["stage", "--scan", ".", "--non-interactive"], Some(path))?;
437
438        // Stage 2: Commit with identity.
439        let stdout = LoreProcessRunner::run(
440            [
441                "revision",
442                "commit",
443                message,
444                "--identity",
445                author,
446                "--non-interactive",
447            ],
448            Some(path),
449        )?;
450
451        // Parse the revision signature from stdout. Lore now outputs a
452        // multi-line report. We look for the "Signature :" line.
453        let signature = stdout
454            .lines()
455            .find_map(|line| {
456                line.strip_prefix("Signature :")
457                    .or_else(|| line.strip_prefix("Signature:"))
458            })
459            .map(|s| s.trim().to_string())
460            .unwrap_or_else(|| {
461                // Fallback: try the old "Created revision <sig> (#<num>)" format.
462                stdout
463                    .lines()
464                    .next()
465                    .unwrap_or(&stdout)
466                    .trim()
467                    .strip_prefix("Created revision ")
468                    .and_then(|s| s.split_whitespace().next())
469                    .map(|s| s.to_string())
470                    .unwrap_or_else(|| stdout.trim().to_string())
471            });
472
473        Ok(signature)
474    }
475
476    // ── read_file_at_ref ─────────────────────────────────────────────
477    fn read_file_at_ref(
478        &self,
479        repo_path: &Path,
480        file_path: &str,
481        _reference: Option<&str>,
482    ) -> Result<String, NapError> {
483        // lore file cat was removed from the CLI. Since the workspace is
484        // cloned at the current branch, read directly from disk.
485        let full_path = repo_path.join(file_path);
486        std::fs::read_to_string(&full_path).map_err(|e| {
487            NapError::VcsError(format!("failed to read {}: {}", full_path.display(), e))
488        })
489    }
490
491    // ── log ──────────────────────────────────────────────────────────
492    fn log(
493        &self,
494        path: &Path,
495        _file: Option<&str>,
496        limit: usize,
497    ) -> Result<Vec<CommitInfo>, NapError> {
498        let limit_str = limit.to_string();
499        let args = vec!["history", &limit_str, "--non-interactive"];
500
501        let stdout = LoreProcessRunner::run(&args, Some(path))?;
502
503        if stdout.trim().is_empty() {
504            return Ok(Vec::new());
505        }
506
507        // Parse plain text output. Each revision is a block:
508        //   Revision  : N
509        //   Signature : <hex>
510        //   Branch    : <id>
511        //   Date      : <date>
512        //       <message>
513        //   Creator   : <author>
514        //   Committer : <author>
515        let mut commits = Vec::new();
516        let mut current_signature = String::new();
517        let mut current_author = String::new();
518        let mut current_message = String::new();
519        let mut current_timestamp = String::new();
520        let mut current_parent: Option<String> = None;
521        let mut in_message = false;
522
523        for line in stdout.lines() {
524            let trimmed = line.trim();
525            if trimmed.starts_with("Signature :") || trimmed.starts_with("Signature:") {
526                // Save previous commit if we have one.
527                if !current_signature.is_empty() {
528                    commits.push(CommitInfo {
529                        id: std::mem::take(&mut current_signature),
530                        parent: current_parent.take(),
531                        author: std::mem::take(&mut current_author),
532                        message: std::mem::take(&mut current_message),
533                        timestamp: std::mem::take(&mut current_timestamp),
534                    });
535                }
536                current_signature = trimmed
537                    .strip_prefix("Signature :")
538                    .or_else(|| trimmed.strip_prefix("Signature:"))
539                    .unwrap_or("")
540                    .trim()
541                    .to_string();
542                in_message = false;
543            } else if trimmed.starts_with("Date      :") || trimmed.starts_with("Date:") {
544                current_timestamp = trimmed
545                    .split_once(':')
546                    .map(|(_, v)| v.trim().to_string())
547                    .unwrap_or_default();
548                in_message = true;
549            } else if trimmed.starts_with("Creator   :") || trimmed.starts_with("Creator:") {
550                current_author = trimmed
551                    .split_once(':')
552                    .map(|(_, v)| v.trim().to_string())
553                    .unwrap_or_default();
554                in_message = false;
555            } else if trimmed.starts_with("Revision  :")
556                || trimmed.starts_with("Revision:")
557                || trimmed.starts_with("Branch    :")
558                || trimmed.starts_with("Branch:")
559                || trimmed.starts_with("Committer :")
560                || trimmed.starts_with("Committer:")
561            {
562                in_message = false;
563            } else if in_message {
564                if trimmed.is_empty() || trimmed == "Commit succeeded" {
565                    in_message = false;
566                } else {
567                    if !current_message.is_empty() {
568                        current_message.push('\n');
569                    }
570                    current_message.push_str(trimmed);
571                }
572            }
573        }
574        // Push the last commit.
575        if !current_signature.is_empty() {
576            commits.push(CommitInfo {
577                id: current_signature,
578                parent: current_parent,
579                author: current_author,
580                message: current_message,
581                timestamp: current_timestamp,
582            });
583        }
584
585        Ok(commits)
586    }
587
588    // ── branching ────────────────────────────────────────────────────
589    fn create_branch(&self, path: &Path, name: &str) -> Result<(), NapError> {
590        LoreProcessRunner::run(["branch", "create", name, "--non-interactive"], Some(path))?;
591        Ok(())
592    }
593
594    fn switch_branch(&self, path: &Path, name: &str) -> Result<(), NapError> {
595        LoreProcessRunner::run(["branch", "switch", name, "--non-interactive"], Some(path))?;
596        Ok(())
597    }
598
599    fn current_branch(&self, path: &Path) -> Result<String, NapError> {
600        let stdout = LoreProcessRunner::run(["branch", "show", "--non-interactive"], Some(path))?;
601        Ok(stdout.trim().to_string())
602    }
603
604    fn list_branches(&self, path: &Path) -> Result<Vec<String>, NapError> {
605        let stdout = LoreProcessRunner::run(["branch", "list", "--non-interactive"], Some(path))?;
606        if stdout.is_empty() {
607            return Ok(Vec::new());
608        }
609        // Parse plain text output:
610        //   Local branches:
611        //   * main
612        //     feature-x
613        //   Remote branches:
614        //     main
615        let mut branches = Vec::new();
616        let mut in_local = false;
617        for line in stdout.lines() {
618            let trimmed = line.trim();
619            if trimmed.starts_with("Local branches") {
620                in_local = true;
621                continue;
622            }
623            if trimmed.starts_with("Remote branches") {
624                in_local = false;
625                continue;
626            }
627            if in_local && !trimmed.is_empty() {
628                // Strip "* " prefix for current branch marker.
629                let name = trimmed.strip_prefix("* ").unwrap_or(trimmed);
630                branches.push(name.to_string());
631            }
632        }
633        Ok(branches)
634    }
635
636    // ── head / revert ────────────────────────────────────────────────
637    fn head_hash(&self, path: &Path) -> Result<String, NapError> {
638        let stdout = LoreProcessRunner::run(["history", "1", "--non-interactive"], Some(path))?;
639
640        if stdout.trim().is_empty() {
641            return Err(NapError::VcsError(
642                "no commits in lore workspace".to_string(),
643            ));
644        }
645
646        // Parse "Signature : <hex>" from plain text output.
647        stdout
648            .lines()
649            .find_map(|line| {
650                line.trim()
651                    .strip_prefix("Signature :")
652                    .or_else(|| line.trim().strip_prefix("Signature:"))
653            })
654            .map(|s| s.trim().to_string())
655            .ok_or_else(|| {
656                NapError::VcsError(format!(
657                    "failed to parse signature from lore history: {stdout}"
658                ))
659            })
660    }
661
662    fn revert(&self, path: &Path, commit_hash: &str) -> Result<String, NapError> {
663        let stdout = LoreProcessRunner::run(
664            ["revision", "revert", commit_hash, "--non-interactive"],
665            Some(path),
666        )?;
667        // Lore outputs: "Created revert revision <signature>"
668        let signature = stdout
669            .trim()
670            .strip_prefix("Created revert revision ")
671            .unwrap_or(stdout.trim());
672        Ok(signature.to_string())
673    }
674
675    fn resolve_branch_head(&self, path: &Path, branch: &str) -> Result<String, NapError> {
676        let stdout = LoreProcessRunner::run(
677            ["history", "1", "--branch", branch, "--non-interactive"],
678            Some(path),
679        )?;
680
681        if stdout.trim().is_empty() {
682            return Err(NapError::VcsError(format!(
683                "no commits found on branch '{branch}'"
684            )));
685        }
686
687        // Parse "Signature : <hex>" from plain text output.
688        stdout
689            .lines()
690            .find_map(|line| {
691                line.trim()
692                    .strip_prefix("Signature :")
693                    .or_else(|| line.trim().strip_prefix("Signature:"))
694            })
695            .map(|s| s.trim().to_string())
696            .ok_or_else(|| {
697                NapError::VcsError(format!(
698                    "failed to parse signature from lore history on branch '{branch}': {stdout}"
699                ))
700            })
701    }
702
703    // ── remotes ──────────────────────────────────────────────────────
704    fn add_remote(&self, path: &Path, name: &str, url: &str) -> Result<(), NapError> {
705        LoreProcessRunner::run(
706            [
707                "repository",
708                "add",
709                url,
710                "--alias",
711                name,
712                "--non-interactive",
713            ],
714            Some(path),
715        )?;
716        Ok(())
717    }
718
719    fn remove_remote(&self, path: &Path, name: &str) -> Result<(), NapError> {
720        LoreProcessRunner::run(
721            ["repository", "remove", "--alias", name, "--non-interactive"],
722            Some(path),
723        )?;
724        Ok(())
725    }
726
727    fn list_remotes(&self, path: &Path) -> Result<Vec<(String, String)>, NapError> {
728        let stdout = LoreProcessRunner::run(
729            [
730                "repository",
731                "list",
732                "--format",
733                "json",
734                "--non-interactive",
735            ],
736            Some(path),
737        )?;
738
739        if stdout.is_empty() || stdout == "[]" || stdout == "null" {
740            return Ok(Vec::new());
741        }
742
743        // Expect JSON array of { "name": "...", "url": "lore://..." }
744        #[derive(serde::Deserialize)]
745        struct RemoteEntry {
746            #[allow(dead_code)]
747            name: String,
748            #[allow(dead_code)]
749            url: String,
750        }
751        let entries: Vec<RemoteEntry> = serde_json::from_str(&stdout).map_err(|e| {
752            NapError::VcsError(format!(
753                "failed to parse lore repository list JSON: {}. Raw: {}",
754                e, stdout
755            ))
756        })?;
757
758        let pairs: Vec<(String, String)> = entries.into_iter().map(|e| (e.name, e.url)).collect();
759        Ok(pairs)
760    }
761
762    // ── push / pull ──────────────────────────────────────────────────
763    fn push(
764        &self,
765        path: &Path,
766        _remote: Option<&str>,
767        branch: Option<&str>,
768    ) -> Result<(), NapError> {
769        // Resolve the branch name: prefer the caller-supplied value,
770        // fall back to the workspace's current branch, then "main".
771        let branch_name = match branch {
772            Some(b) => b.to_string(),
773            None => self
774                .current_branch(path)
775                .unwrap_or_else(|_| "main".to_string()),
776        };
777
778        // Push branch via lore CLI (handles blob upload + branch tip advancement internally)
779        let args = vec![
780            "branch",
781            "push",
782            &branch_name,
783            "--fast-forward-merge",
784            "--non-interactive",
785        ];
786        LoreProcessRunner::run(&args, Some(path))?;
787
788        Ok(())
789    }
790
791    fn pull(
792        &self,
793        path: &Path,
794        _remote: Option<&str>,
795        _branch: Option<&str>,
796    ) -> Result<(), NapError> {
797        // Sync via lore CLI (handles remote checking + blob download internally)
798        let args = vec!["sync", "--non-interactive", "--reset"];
799        LoreProcessRunner::run(&args, Some(path))?;
800
801        Ok(())
802    }
803}
804
805// ---------------------------------------------------------------------------
806// Tests
807// ---------------------------------------------------------------------------
808
809#[cfg(all(test, feature = "lore-integration"))]
810mod tests {
811    use super::*;
812
813    // ---- LoreProcessRunner tests ---------------------------------------
814
815    #[test]
816    fn test_binary_default() {
817        assert_eq!(LoreProcessRunner::binary(), "lore");
818    }
819
820    #[test]
821    fn test_binary_from_env() {
822        temp_env::with_var("NAPLORE_CLI", Some("/custom/lore"), || {
823            assert_eq!(LoreProcessRunner::binary(), "/custom/lore");
824        });
825    }
826
827    #[test]
828    fn test_run_captures_stdout() {
829        // We can't test a real `lore` call in CI without the binary.
830        // This test verifies the runner returns an error for a missing
831        // binary, which confirms the process-spawning path works.
832        temp_env::with_var("NAPLORE_CLI", Some("lore-nonexistent-binary-12345"), || {
833            let result = LoreProcessRunner::run(["--version"], None);
834            assert!(result.is_err());
835            let err = result.unwrap_err().to_string();
836            assert!(
837                err.contains("lore-nonexistent-binary-12345"),
838                "error: {}",
839                err
840            );
841        });
842    }
843
844    // ---- LoreBackend tests --------------------------------------------
845
846    #[test]
847    fn test_new_and_from_env() {
848        let backend = LoreBackend::new("lore://myhost:8700", "test-workspace");
849        assert_eq!(backend.remote_url, "lore://myhost:8700");
850        assert_eq!(backend.workspace_id, "test-workspace");
851
852        temp_env::with_vars(
853            vec![
854                ("NAP_LORE_URL_BASE", Some("lore://custom:9999")),
855                ("NAP_WORKSPACE_ID", Some("custom-ws")),
856            ],
857            || {
858                let from_env = LoreBackend::from_env();
859                assert_eq!(from_env.remote_url, "lore://custom:9999");
860                assert_eq!(from_env.workspace_id, "custom-ws");
861            },
862        );
863    }
864
865    #[test]
866    fn test_from_env_default_without_env_vars() {
867        // Test default behavior when no env vars are set and no provider config exists
868        let temp_dir = tempfile::TempDir::new().unwrap();
869        let nap_dir_str = temp_dir.path().to_str().unwrap();
870
871        temp_env::with_vars(
872            vec![
873                ("NAP_LORE_URL_BASE", None::<&str>),
874                ("NAP_WORKSPACE_ID", None::<&str>),
875                ("NAP_DIR", Some(nap_dir_str)),
876            ],
877            || {
878                let backend = LoreBackend::from_env();
879                assert_eq!(backend.remote_url, "lore://localhost:41337");
880                assert_eq!(backend.workspace_id, "default");
881            },
882        );
883    }
884
885    #[test]
886    fn test_from_env_env_var_override() {
887        // Test that env vars take precedence over provider config
888        let temp_dir = tempfile::TempDir::new().unwrap();
889        let nap_dir_str = temp_dir.path().to_str().unwrap();
890
891        temp_env::with_vars(
892            vec![
893                ("NAP_LORE_URL_BASE", Some("lore://override:1234")),
894                ("NAP_WORKSPACE_ID", Some("override-ws")),
895                ("NAP_DIR", Some(nap_dir_str)),
896            ],
897            || {
898                let backend = LoreBackend::from_env();
899                assert_eq!(backend.remote_url, "lore://override:1234");
900                assert_eq!(backend.workspace_id, "override-ws");
901            },
902        );
903    }
904
905    #[test]
906    fn test_from_env_partial_env_override() {
907        // Test partial env var override (only URL set, workspace defaults)
908        let temp_dir = tempfile::TempDir::new().unwrap();
909        let nap_dir_str = temp_dir.path().to_str().unwrap();
910
911        temp_env::with_vars(
912            vec![
913                ("NAP_LORE_URL_BASE", Some("lore://partial:5678")),
914                ("NAP_WORKSPACE_ID", None::<&str>),
915                ("NAP_DIR", Some(nap_dir_str)),
916            ],
917            || {
918                let backend = LoreBackend::from_env();
919                assert_eq!(backend.remote_url, "lore://partial:5678");
920                assert_eq!(backend.workspace_id, "default");
921            },
922        );
923    }
924
925    #[test]
926    fn test_from_env_provider_config() {
927        // Test provider config reading when env vars are not set
928        let temp_dir = tempfile::TempDir::new().unwrap();
929        let provider_config = temp_dir.path().join("provider.toml");
930        std::fs::write(
931            &provider_config,
932            r#"
933provider_type = "remote"
934remote_url = "lore://provider:9999"
935workspace_id = "provider-ws"
936"#,
937        )
938        .unwrap();
939
940        let nap_dir_str = temp_dir.path().to_str().unwrap();
941        temp_env::with_vars(
942            vec![
943                ("NAP_LORE_URL_BASE", None::<&str>),
944                ("NAP_WORKSPACE_ID", None::<&str>),
945                ("NAP_DIR", Some(nap_dir_str)),
946            ],
947            || {
948                let backend = LoreBackend::from_env();
949                assert_eq!(backend.remote_url, "lore://provider:9999");
950                assert_eq!(backend.workspace_id, "provider-ws");
951            },
952        );
953    }
954
955    #[test]
956    fn test_from_env_nap_dir_with_tilde() {
957        // Test NAP_DIR with ~ expansion
958        let _home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
959        let temp_dir = tempfile::TempDir::new().unwrap();
960        let nap_dir_str = temp_dir.path().to_str().unwrap();
961
962        temp_env::with_vars(
963            vec![
964                ("NAP_LORE_URL_BASE", None::<&str>),
965                ("NAP_WORKSPACE_ID", None::<&str>),
966                ("NAP_DIR", Some(nap_dir_str)),
967            ],
968            || {
969                let backend = LoreBackend::from_env();
970                // Should use defaults since provider config doesn't exist
971                assert_eq!(backend.remote_url, "lore://localhost:41337");
972                assert_eq!(backend.workspace_id, "default");
973            },
974        );
975    }
976
977    #[test]
978    fn test_from_env_local_provider_config() {
979        // Test local provider configuration
980        let temp_dir = tempfile::TempDir::new().unwrap();
981        let provider_config = temp_dir.path().join("provider.toml");
982        std::fs::write(
983            &provider_config,
984            r#"
985provider_type = "local"
986"#,
987        )
988        .unwrap();
989
990        let nap_dir_str = temp_dir.path().to_str().unwrap();
991        temp_env::with_vars(
992            vec![
993                ("NAP_LORE_URL_BASE", None::<&str>),
994                ("NAP_WORKSPACE_ID", None::<&str>),
995                ("NAP_DIR", Some(nap_dir_str)),
996            ],
997            || {
998                let backend = LoreBackend::from_env();
999                assert_eq!(backend.remote_url, "lore://localhost:41337");
1000                assert_eq!(backend.workspace_id, "default");
1001            },
1002        );
1003    }
1004
1005    #[test]
1006    fn test_from_env_portals_cloud_provider_config() {
1007        // Test portals-cloud provider configuration
1008        let temp_dir = tempfile::TempDir::new().unwrap();
1009        let provider_config = temp_dir.path().join("provider.toml");
1010        std::fs::write(
1011            &provider_config,
1012            r#"
1013provider_type = "portals-cloud"
1014workspace_id = "cloud-ws"
1015"#,
1016        )
1017        .unwrap();
1018
1019        let nap_dir_str = temp_dir.path().to_str().unwrap();
1020        temp_env::with_vars(
1021            vec![
1022                ("NAP_LORE_URL_BASE", None::<&str>),
1023                ("NAP_WORKSPACE_ID", None::<&str>),
1024                ("NAP_DIR", Some(nap_dir_str)),
1025            ],
1026            || {
1027                let backend = LoreBackend::from_env();
1028                assert_eq!(backend.remote_url, PORTALS_CLOUD_URL);
1029                assert_eq!(backend.workspace_id, "cloud-ws");
1030            },
1031        );
1032    }
1033
1034    #[test]
1035    fn test_from_env_portals_cloud_default_workspace() {
1036        // Test portals-cloud with default workspace
1037        let temp_dir = tempfile::TempDir::new().unwrap();
1038        let provider_config = temp_dir.path().join("provider.toml");
1039        std::fs::write(
1040            &provider_config,
1041            r#"
1042provider_type = "portals-cloud"
1043"#,
1044        )
1045        .unwrap();
1046
1047        let nap_dir_str = temp_dir.path().to_str().unwrap();
1048        temp_env::with_vars(
1049            vec![
1050                ("NAP_LORE_URL_BASE", None::<&str>),
1051                ("NAP_WORKSPACE_ID", None::<&str>),
1052                ("NAP_DIR", Some(nap_dir_str)),
1053            ],
1054            || {
1055                let backend = LoreBackend::from_env();
1056                assert_eq!(backend.remote_url, PORTALS_CLOUD_URL);
1057                assert_eq!(backend.workspace_id, "default");
1058            },
1059        );
1060    }
1061
1062    #[test]
1063    fn test_from_env_unknown_provider_type() {
1064        // Test unknown provider type falls back to defaults
1065        let temp_dir = tempfile::TempDir::new().unwrap();
1066        let provider_config = temp_dir.path().join("provider.toml");
1067        std::fs::write(
1068            &provider_config,
1069            r#"
1070provider_type = "unknown-provider"
1071"#,
1072        )
1073        .unwrap();
1074
1075        let nap_dir_str = temp_dir.path().to_str().unwrap();
1076        temp_env::with_vars(
1077            vec![
1078                ("NAP_LORE_URL_BASE", None::<&str>),
1079                ("NAP_WORKSPACE_ID", None::<&str>),
1080                ("NAP_DIR", Some(nap_dir_str)),
1081            ],
1082            || {
1083                let backend = LoreBackend::from_env();
1084                assert_eq!(backend.remote_url, "lore://localhost:41337");
1085                assert_eq!(backend.workspace_id, "default");
1086            },
1087        );
1088    }
1089
1090    #[test]
1091    fn test_repo_url_joining() {
1092        let backend = LoreBackend::new("lore://localhost:8700", "ws");
1093        assert_eq!(backend.repo_url("my-repo"), "lore://localhost:8700/my-repo");
1094
1095        // With trailing slash.
1096        let backend2 = LoreBackend::new("lore://host:8700/", "ws");
1097        assert_eq!(backend2.repo_url("foo"), "lore://host:8700/foo");
1098    }
1099
1100    #[test]
1101    fn test_list_branches_empty_json() {
1102        // Verify the edge case guards work for empty/bogus stdout.
1103        // The `[]` and `null` branches of `list_branches` are tested
1104        // through unit coverage of the deserialisation logic in `log`.
1105        // edge-case guards checked in production code
1106    }
1107
1108    #[test]
1109    fn test_commit_parses_signature_from_stdout() {
1110        // We can't call the real commit, but we can check the stdout
1111        // parse path is wired in: the `commit` impl extracts the first
1112        // whitespace token after "Created revision ".
1113        let sample = "Created revision a1b2c3d4 (#42)";
1114        let signature = sample
1115            .strip_prefix("Created revision ")
1116            .and_then(|s| s.split_whitespace().next())
1117            .unwrap_or(sample);
1118        assert_eq!(signature, "a1b2c3d4");
1119    }
1120
1121    // ---- CommitInfo from_lore_revision test -------------------------
1122
1123    #[test]
1124    fn test_commit_info_from_lore_revision() {
1125        let info = CommitInfo::from_lore_revision(
1126            "sig123",
1127            Some("sig122"),
1128            "alice",
1129            "feat: add manifest",
1130            "2026-06-30T12:00:00Z",
1131        );
1132        assert_eq!(info.id, "sig123");
1133        assert_eq!(info.parent.as_deref(), Some("sig122"));
1134        assert_eq!(info.author, "alice");
1135        assert_eq!(info.message, "feat: add manifest");
1136        assert_eq!(info.timestamp, "2026-06-30T12:00:00Z");
1137    }
1138
1139    #[test]
1140    fn test_commit_info_default_timestamp() {
1141        // When timestamp is empty, we expect an RFC 3339 timestamp.
1142        let info = CommitInfo::from_lore_revision("sig", None, "bob", "msg", "");
1143        assert!(
1144            info.timestamp.contains('T') || info.timestamp.contains('Z'),
1145            "expected RFC 3339 timestamp, got: {}",
1146            info.timestamp
1147        );
1148    }
1149}