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