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