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)
50use crate::provider::portals_cloud::PORTALS_CLOUD_URL;
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 select_http_token(
186    events: &str,
187    repository: &str,
188    user: &str,
189    origin: &str,
190    now_ms: u64,
191) -> Result<Option<String>, NapError> {
192    let url = crate::provider::http::validate_origin(origin)
193        .map_err(|e| NapError::Other(e.to_string()))?;
194    let host = url.host_str().unwrap();
195    let mut selected = None;
196    for line in events.lines().filter(|line| !line.trim().is_empty()) {
197        // Never include the event or token in parse errors.
198        let event: serde_json::Value = serde_json::from_str(line)
199            .map_err(|_| NapError::VcsError("invalid Lore identity response".into()))?;
200        if event["tagName"] != "authIdentity" {
201            continue;
202        }
203        let data = &event["data"];
204        if data["resource"].as_str() != Some(repository)
205            || data["userId"].as_str() != Some(user)
206            || data["expires"].as_u64().unwrap_or(0) <= now_ms
207        {
208            continue;
209        }
210        let authorized = data["authorizedDomains"]
211            .as_str()
212            .unwrap_or("")
213            .split(',')
214            .any(|domain| {
215                let domain = domain.trim().to_ascii_lowercase();
216                !domain.is_empty()
217                    && (host.eq_ignore_ascii_case(&domain)
218                        || host.to_ascii_lowercase().ends_with(&format!(".{domain}")))
219            });
220        if !authorized {
221            continue;
222        }
223        if let Some(token) = data["token"].as_str().filter(|s| !s.is_empty()) {
224            if selected
225                .as_deref()
226                .is_some_and(|previous| previous != token)
227            {
228                return Err(NapError::VcsError(
229                    "ambiguous Lore repository credentials; run nap auth login".into(),
230                ));
231            }
232            selected = Some(token.to_string());
233        }
234    }
235    Ok(selected)
236}
237
238fn event_string(data: &serde_json::Value, field: &str) -> Result<String, String> {
239    data.get(field)
240        .and_then(serde_json::Value::as_str)
241        .filter(|value| !value.is_empty())
242        .map(str::to_owned)
243        .ok_or_else(|| format!("Lore {field} is missing or is not a string"))
244}
245
246fn validate_lower_hex(value: &str, bytes: usize, label: &str) -> Result<(), String> {
247    if value.len() != bytes * 2 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
248        return Err(format!("Lore returned an invalid {label}"));
249    }
250    Ok(())
251}
252
253fn hydrate_lore_file(
254    repo_path: &Path,
255    args: impl IntoIterator<Item = String>,
256    prefix: &str,
257) -> Result<Vec<u8>, NapError> {
258    // A private directory prevents another local user from replacing the
259    // predictable output path with a symlink while Lore is writing it.
260    let temp_dir = tempfile::Builder::new()
261        .prefix(&format!("nap-{prefix}-"))
262        .tempdir()
263        .map_err(|e| NapError::VcsError(format!("failed to create private temp directory: {e}")))?;
264    let output_path = temp_dir.path().join("content");
265    let output = output_path.to_string_lossy().into_owned();
266    let mut command_args: Vec<String> = args.into_iter().collect();
267    command_args.extend([
268        "--output".to_string(),
269        output,
270        "--non-interactive".to_string(),
271    ]);
272    LoreProcessRunner::run(command_args, Some(repo_path))?;
273    std::fs::read(&output_path).map_err(|e| {
274        NapError::VcsError(format!(
275            "failed to read Lore output {}: {e}",
276            output_path.display()
277        ))
278    })
279}
280
281fn parse_metadata_output(stdout: &str) -> Result<BTreeMap<String, String>, String> {
282    if let Ok(value) = serde_json::from_str::<serde_json::Value>(stdout) {
283        let mut metadata = BTreeMap::new();
284        if let serde_json::Value::Object(map) = value {
285            for (key, value) in map {
286                let rendered = match value {
287                    serde_json::Value::String(s) => s,
288                    serde_json::Value::Bool(b) => b.to_string(),
289                    serde_json::Value::Number(n) => n.to_string(),
290                    serde_json::Value::Null => continue,
291                    other => serde_json::to_string(&other).map_err(|e| e.to_string())?,
292                };
293                metadata.insert(key, rendered);
294            }
295        }
296        return Ok(metadata);
297    }
298
299    let mut metadata = BTreeMap::new();
300    for line in stdout.lines() {
301        let trimmed = line.trim();
302        if trimmed.is_empty() {
303            continue;
304        }
305        if let Some((key, value)) = trimmed.split_once('=').or_else(|| trimmed.split_once(':')) {
306            let key = key.trim();
307            if !key.is_empty() {
308                metadata.insert(key.to_string(), value.trim().to_string());
309            }
310        }
311    }
312    Ok(metadata)
313}
314
315// ---------------------------------------------------------------------------
316// LoreBackend
317// ---------------------------------------------------------------------------
318
319/// A [`VcsBackend`] implementation backed by the Lore VCS CLI (`lore(1)`).
320///
321/// `LoreBackend` requires a remote `lore://` URL and a workspace identity
322/// so that it can call `lore repository create` / `lore clone` during init.
323///
324/// Use [`LoreBackend::new()`] for the default configuration
325/// (reads env-var overrides for the server URL, or falls back to a
326/// local-dev default).
327#[derive(Debug, Clone)]
328pub struct LoreBackend {
329    /// The `lore://` remote URL for the repository.
330    remote_url: String,
331    /// Workspace identifier (multi-tenancy scope).
332    workspace_id: String,
333}
334
335impl LoreBackend {
336    /// Construct a backend using a specific NAP home rather than whichever
337    /// directory happens to be in `NAP_DIR`.
338    pub fn from_nap_home(nap_home: &Path) -> Self {
339        if std::env::var("NAP_LORE_URL_BASE").is_ok() || std::env::var("NAP_WORKSPACE_ID").is_ok() {
340            return Self::from_env();
341        }
342        let workspace_id = std::fs::read_to_string(nap_home.join("provider.toml"))
343            .ok()
344            .and_then(|content| toml::from_str::<ProviderConfigToml>(&content).ok())
345            .and_then(|config| config.workspace_id)
346            .unwrap_or_else(|| "default".to_string());
347        Self::from_provider(&Self::configured_server_url(nap_home), &workspace_id)
348    }
349    /// Resolve the configured Lore server for a NAP home. This is shared by
350    /// remote readers so `--base-dir` is respected instead of silently
351    /// consulting a different `NAP_DIR`.
352    pub fn configured_server_url(nap_home: &Path) -> String {
353        if let Ok(url) = std::env::var("NAP_LORE_URL_BASE") {
354            return url;
355        }
356        let config_path = nap_home.join("provider.toml");
357        if let Ok(content) = std::fs::read_to_string(config_path)
358            && let Ok(config) = toml::from_str::<ProviderConfigToml>(&content)
359        {
360            match config.provider_type.as_str() {
361                "remote" => {
362                    if let Some(url) = config.remote_url {
363                        return url;
364                    }
365                }
366                "portals-cloud" => return PORTALS_CLOUD_URL.to_string(),
367                "local" => return "lore://localhost:41337".to_string(),
368                _ => {}
369            }
370        }
371        "lore://localhost:41337".to_string()
372    }
373    /// Create a new Lore backend.
374    ///
375    /// `remote_url` should be a `lore://host/repository` URL.
376    /// `workspace_id` scopes the repository to a multi-tenant workspace.
377    pub fn new(remote_url: &str, workspace_id: &str) -> Self {
378        Self {
379            remote_url: remote_url.to_string(),
380            workspace_id: workspace_id.to_string(),
381        }
382    }
383
384    pub fn remote_url(&self) -> &str {
385        &self.remote_url
386    }
387
388    /// Clone a remote Lore repository to a local path.
389    ///
390    /// Equivalent to `lore clone <url> <dest>`.  Does NOT require an
391    /// existing `LoreBackend` instance — use this when you just want
392    /// to clone and don't need a full backend.
393    pub fn clone_repo(url: &str, dest: &Path) -> Result<(), NapError> {
394        LoreProcessRunner::run(
395            [
396                "clone",
397                url,
398                dest.to_str().unwrap_or("."),
399                "--non-interactive",
400            ],
401            None,
402        )?;
403        Ok(())
404    }
405
406    /// Clone only the requested repository-root files and their dependencies.
407    /// Lore keeps the resulting checkout sparse while still materialising the
408    /// NAP manifests needed by an entity pull.
409    pub fn clone_repo_with_root_files(
410        url: &str,
411        dest: &Path,
412        root_files: &[String],
413    ) -> Result<(), NapError> {
414        let mut args = vec![
415            "clone".to_string(),
416            url.to_string(),
417            dest.to_string_lossy().to_string(),
418            "--non-interactive".to_string(),
419        ];
420        for root_file in root_files {
421            args.push("--root-file".to_string());
422            args.push(root_file.clone());
423        }
424        LoreProcessRunner::run(args.iter().map(String::as_str), None)?;
425        Ok(())
426    }
427
428    /// Synchronize selected root files in an existing Lore working tree.
429    pub fn sync_root_files(dest: &Path, root_files: &[String]) -> Result<(), NapError> {
430        let mut args = vec!["revision".to_string(), "sync".to_string()];
431        for root_file in root_files {
432            args.push("--root-file".to_string());
433            args.push(root_file.clone());
434        }
435        LoreProcessRunner::run(args.iter().map(String::as_str), Some(dest))?;
436        Ok(())
437    }
438
439    /// Convenience constructor that reads configuration from environment
440    /// variables with sensible local-development defaults.
441    ///
442    /// Precedence: env vars > provider config > defaults
443    ///
444    /// | Env var               | Default                   |
445    /// |-----------------------|---------------------------|
446    /// | `NAP_LORE_URL_BASE`   | provider-dependent; local uses `lore://localhost:41337` |
447    /// | `NAP_WORKSPACE_ID`    | `default`                 |
448    ///
449    /// Note: For new code, prefer using the RepositoryApi with Provider architecture
450    /// instead of this legacy environment-based constructor.
451    pub fn from_env() -> Self {
452        // Ensure the Lore server is running
453        if let Ok(nap_dir) = std::env::var("NAP_DIR") {
454            let manager = crate::server::manager::ServerManager::new(Path::new(&nap_dir));
455            let _ = tokio::runtime::Handle::try_current().map(|handle| {
456                handle.block_on(async {
457                    let _ = manager.ensure_running().await;
458                });
459            });
460        }
461
462        // Priority 1: Environment variables (for testing/override)
463        let url_from_env = std::env::var("NAP_LORE_URL_BASE").ok();
464        let workspace_from_env = std::env::var("NAP_WORKSPACE_ID").ok();
465
466        if url_from_env.is_some() || workspace_from_env.is_some() {
467            let base = url_from_env.unwrap_or_else(|| "lore://localhost:41337".to_string());
468            let workspace_id = workspace_from_env.unwrap_or_else(|| "default".to_string());
469            tracing::debug!(
470                url_base = %base,
471                workspace_id = %workspace_id,
472                "LoreBackend::from_env using environment variables (override)"
473            );
474            return Self {
475                remote_url: base,
476                workspace_id,
477            };
478        }
479
480        // Priority 2: Provider configuration from --base-dir's provider.toml (for cmd_init_universe)
481        // Check base_dir hinted via NAP_INIT_BASE_DIR (set by nap-cli) before falling back to NAP_DIR.
482        // Handles all provider types (local, remote, portals-cloud) for atomic init.
483        if let Ok(base_dir_str) = std::env::var("NAP_INIT_BASE_DIR") {
484            let base_path = PathBuf::from(&base_dir_str);
485            let provider_config_path = base_path.join("provider.toml");
486            if provider_config_path.exists()
487                && let Ok(config_content) = std::fs::read_to_string(&provider_config_path)
488                && let Ok(config) = toml::from_str::<ProviderConfigToml>(&config_content)
489            {
490                match config.provider_type.as_str() {
491                    "local" => {
492                        tracing::debug!(
493                            url_base = "lore://localhost:41337",
494                            workspace_id = "default",
495                            "LoreBackend::from_env using local provider from NAP_INIT_BASE_DIR"
496                        );
497                        return Self {
498                            remote_url: "lore://localhost:41337".to_string(),
499                            workspace_id: "default".to_string(),
500                        };
501                    }
502                    "remote" => {
503                        if let (Some(url), Some(workspace)) =
504                            (config.remote_url, config.workspace_id)
505                        {
506                            tracing::debug!(
507                                url_base = %url,
508                                workspace_id = %workspace,
509                                "LoreBackend::from_env using remote provider from NAP_INIT_BASE_DIR"
510                            );
511                            return Self {
512                                remote_url: url,
513                                workspace_id: workspace,
514                            };
515                        }
516                    }
517                    "portals-cloud" => {
518                        let workspace_id =
519                            config.workspace_id.unwrap_or_else(|| "default".to_string());
520                        tracing::debug!(
521                            url_base = %PORTALS_CLOUD_URL,
522                            workspace_id = %workspace_id,
523                            "LoreBackend::from_env using portals-cloud provider from NAP_INIT_BASE_DIR"
524                        );
525                        return Self {
526                            remote_url: PORTALS_CLOUD_URL.to_string(),
527                            workspace_id,
528                        };
529                    }
530                    _ => {}
531                }
532            }
533        }
534
535        // Priority 2b: Provider configuration from NAP_DIR
536        let nap_dir = if let Ok(nap_dir_str) = std::env::var("NAP_DIR") {
537            // Expand ~ in NAP_DIR if present (same logic as nap-cli expand_path)
538            let path = PathBuf::from(&nap_dir_str);
539            if let Some(s) = path.to_str() {
540                if let Some(stripped) = s.strip_prefix('~') {
541                    let home = std::env::var("HOME")
542                        .or_else(|_| std::env::var("USERPROFILE"))
543                        .unwrap_or_else(|_| ".".to_string());
544                    PathBuf::from(home).join(stripped.trim_start_matches('/'))
545                } else {
546                    path
547                }
548            } else {
549                path
550            }
551        } else {
552            // Default to ~/.nap if NAP_DIR is not set
553            let home = std::env::var("HOME")
554                .or_else(|_| std::env::var("USERPROFILE"))
555                .unwrap_or_else(|_| ".".to_string());
556            PathBuf::from(home).join(".nap")
557        };
558
559        let provider_config_path = nap_dir.join("provider.toml");
560        if provider_config_path.exists()
561            && let Ok(config_content) = std::fs::read_to_string(&provider_config_path)
562            && let Ok(config) = toml::from_str::<ProviderConfigToml>(&config_content)
563        {
564            match config.provider_type.as_str() {
565                "local" => {
566                    // Local provider uses localhost defaults
567                    tracing::debug!(
568                        url_base = "lore://localhost:41337",
569                        workspace_id = "default",
570                        "LoreBackend::from_env using local provider configuration"
571                    );
572                    return Self {
573                        remote_url: "lore://localhost:41337".to_string(),
574                        workspace_id: "default".to_string(),
575                    };
576                }
577                "remote" => {
578                    // Remote provider uses configured URL and workspace
579                    if let (Some(url), Some(workspace)) = (config.remote_url, config.workspace_id) {
580                        tracing::debug!(
581                            url_base = %url,
582                            workspace_id = %workspace,
583                            "LoreBackend::from_env using remote provider configuration"
584                        );
585                        return Self {
586                            remote_url: url,
587                            workspace_id: workspace,
588                        };
589                    }
590                }
591                "portals-cloud" => {
592                    // Portals Cloud uses hardcoded URL (env vars already checked above)
593                    let workspace_id = config.workspace_id.unwrap_or_else(|| "default".to_string());
594                    tracing::debug!(
595                        url_base = %PORTALS_CLOUD_URL,
596                        workspace_id = %workspace_id,
597                        "LoreBackend::from_env using portals-cloud provider configuration"
598                    );
599                    return Self {
600                        remote_url: PORTALS_CLOUD_URL.to_string(),
601                        workspace_id,
602                    };
603                }
604                _ => {
605                    tracing::debug!(
606                        provider_type = %config.provider_type,
607                        "Unknown provider type, falling back to defaults"
608                    );
609                }
610            }
611        }
612
613        // Priority 3: Defaults
614        let base = "lore://localhost:41337".to_string();
615        let workspace_id = "default".to_string();
616        tracing::debug!(
617            url_base = %base,
618            workspace_id = %workspace_id,
619            "LoreBackend::from_env using defaults"
620        );
621        Self {
622            remote_url: base,
623            workspace_id,
624        }
625    }
626
627    /// Create LoreBackend from provider configuration
628    ///
629    /// This is the preferred constructor for new code using the Provider architecture.
630    pub fn from_provider(url_base: &str, workspace_id: &str) -> Self {
631        tracing::debug!(
632            url_base = %url_base,
633            workspace_id = %workspace_id,
634            "Creating LoreBackend from provider configuration"
635        );
636
637        Self {
638            remote_url: url_base.to_string(),
639            workspace_id: workspace_id.to_string(),
640        }
641    }
642
643    /// Build a `lore::` remote URL for a given repository ID.
644    fn repo_url(&self, repo_id: &str) -> String {
645        format!("{}/{}", self.remote_url.trim_end_matches('/'), repo_id)
646    }
647}
648
649impl VcsBackend for LoreBackend {
650    /// Get the remote URL base for constructing repository URLs.
651    fn remote_url_base(&self) -> Result<String, NapError> {
652        Ok(self.remote_url.clone())
653    }
654
655    // ── init ─────────────────────────────────────────────────────────
656    fn init(&self, path: &Path) -> Result<(), NapError> {
657        // For Lore, "init" means:
658        //   1. `lore repository create <repo_url> --id <ws> --repository <server_path>`
659        //   2. `lore clone <repo_url> <local_path>`
660        //
661        // We derive a repo id from the leaf directory of `path`.
662        // The server-side data is stored at `<parent>/.lore-server/<repo_id>`
663        // to avoid collision with the clone destination.
664
665        let raw_id = path
666            .file_name()
667            .and_then(|n| n.to_str())
668            .unwrap_or("nap-repo");
669        // Defensive: `cmd_init_universe` creates a temp dir `base_dir/nap_init_<ts>`
670        // or `base_dir/<repo>_<ts>` and then `Repository::init_optional(&tmp, repo, vcs)`
671        // writes `tmp/repository.yaml` with `id: nap://<repo>/world/<repo>`.
672        // `LoreBackend::init` historically derived `repo_id` from `tmp.file_name()`
673        // (e.g. `.__nap_init_…` or `nap_init_…`) and created `grpcs://…/.__nap_init_…`
674        // on the remote — rejected by `store.validate_resource` → `Not authorized`.
675        // Prefer the canonical repository name from `repository.yaml` (`id` field)
676        // when it exists; fall back to sanitized leaf.
677        let repo_id = {
678            let from_manifest = path
679                .join("repository.yaml")
680                .exists()
681                .then(|| {
682                    std::fs::read_to_string(path.join("repository.yaml"))
683                        .ok()
684                        .and_then(|c| {
685                            serde_yaml::from_str::<serde_yaml::Value>(&c)
686                                .ok()
687                                .and_then(|v| {
688                                    v.get("id").and_then(|id| id.as_str()).and_then(|id_str| {
689                                        // id is "nap://<repository>/world/<repository>" or "nap://<repo>/<type>/<id>"
690                                        id_str.strip_prefix("nap://").and_then(|rest| {
691                                            rest.split('/').next().map(|s| s.to_string())
692                                        })
693                                    })
694                                })
695                        })
696                })
697                .flatten()
698                .filter(|s| {
699                    !s.is_empty()
700                        && s.chars()
701                            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
702                });
703            from_manifest.unwrap_or_else(|| {
704                let sanitized = raw_id.trim_start_matches(['.', '_']);
705                if sanitized.is_empty()
706                    || !sanitized
707                        .chars()
708                        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
709                {
710                    "nap-repo".to_string()
711                } else {
712                    sanitized.to_string()
713                }
714            })
715        };
716
717        let url = self.repo_url(&repo_id);
718        let path_str = path.to_str().unwrap_or(".");
719
720        // Server-side storage lives alongside the repo, not inside it.
721        let server_path = path
722            .parent()
723            .unwrap_or(path)
724            .join(".lore-server")
725            .join(repo_id);
726
727        // Step 1: Create the remote repository.
728        LoreProcessRunner::run(
729            [
730                "repository",
731                "create",
732                &url,
733                "--id",
734                &self.workspace_id,
735                "--repository",
736                server_path.to_str().unwrap_or("."),
737                "--non-interactive",
738            ],
739            None,
740        )
741        .map_err(|e| {
742            NapError::VcsError(format!("failed to create lore repository '{}': {}", url, e))
743        })?;
744
745        // Step 2: Clone it locally.
746        LoreProcessRunner::run(["clone", &url, path_str, "--non-interactive"], None).map_err(
747            |e| {
748                NapError::VcsError(format!(
749                    "failed to clone lore repository to {:?}: {}",
750                    path, e
751                ))
752            },
753        )?;
754
755        Ok(())
756    }
757
758    // ── commit ───────────────────────────────────────────────────────
759    fn commit(&self, path: &Path, message: &str, author: &str) -> Result<String, NapError> {
760        // Lore requires an explicit stage step.
761        // Stage 1: Discover and stage all changes.
762        LoreProcessRunner::run(["stage", "--scan", ".", "--non-interactive"], Some(path))?;
763
764        // Stage 2: Commit with identity.
765        let stdout = LoreProcessRunner::run(
766            [
767                "revision",
768                "commit",
769                message,
770                "--identity",
771                author,
772                "--non-interactive",
773            ],
774            Some(path),
775        )?;
776
777        // Parse the revision signature from stdout. Lore now outputs a
778        // multi-line report. We look for the "Signature :" line.
779        let signature = stdout
780            .lines()
781            .find_map(|line| {
782                line.strip_prefix("Signature :")
783                    .or_else(|| line.strip_prefix("Signature:"))
784            })
785            .map(|s| s.trim().to_string())
786            .unwrap_or_else(|| {
787                // Fallback: try the old "Created revision <sig> (#<num>)" format.
788                stdout
789                    .lines()
790                    .next()
791                    .unwrap_or(&stdout)
792                    .trim()
793                    .strip_prefix("Created revision ")
794                    .and_then(|s| s.split_whitespace().next())
795                    .map(|s| s.to_string())
796                    .unwrap_or_else(|| stdout.trim().to_string())
797            });
798
799        Ok(signature)
800    }
801
802    // ── read_file_at_ref ─────────────────────────────────────────────
803    fn read_file_at_ref(
804        &self,
805        repo_path: &Path,
806        file_path: &str,
807        reference: Option<&str>,
808    ) -> Result<String, NapError> {
809        let bytes = self.read_file_bytes_at_ref(repo_path, file_path, reference)?;
810        String::from_utf8(bytes).map_err(|e| {
811            NapError::VcsError(format!(
812                "{} is not valid UTF-8; use read_file_bytes_at_ref for binary content: {e}",
813                file_path
814            ))
815        })
816    }
817
818    fn read_file_bytes_at_ref(
819        &self,
820        repo_path: &Path,
821        file_path: &str,
822        reference: Option<&str>,
823    ) -> Result<Vec<u8>, NapError> {
824        let Some(reference) = reference else {
825            let full_path = repo_path.join(file_path);
826            return std::fs::read(&full_path).map_err(|e| {
827                NapError::VcsError(format!("failed to read {}: {e}", full_path.display()))
828            });
829        };
830
831        hydrate_lore_file(
832            repo_path,
833            [
834                "file".to_string(),
835                "write".to_string(),
836                "--path".to_string(),
837                file_path.to_string(),
838                "--revision".to_string(),
839                reference.to_string(),
840            ],
841            "file-at-ref",
842        )
843    }
844
845    fn repository_descriptor(&self, repo_path: &Path) -> Result<VcsRepositoryDescriptor, NapError> {
846        let stdout = LoreProcessRunner::run(
847            ["repository", "info", "--json", "--non-interactive"],
848            Some(repo_path),
849        )?;
850        let data = parse_lore_event_data(&stdout, "repositoryData").map_err(|e| {
851            NapError::VcsError(format!("failed to parse Lore repository info: {e}"))
852        })?;
853        let id = event_string(&data, "id").map_err(|e| {
854            NapError::VcsError(format!("failed to parse Lore repository info: {e}"))
855        })?;
856        validate_lower_hex(&id, 16, "repository ID").map_err(|e| {
857            NapError::VcsError(format!("failed to parse Lore repository info: {e}"))
858        })?;
859        let remote_url = data
860            .get("remoteUrl")
861            .and_then(serde_json::Value::as_str)
862            .unwrap_or_default()
863            .to_string();
864        Ok(VcsRepositoryDescriptor { id, remote_url })
865    }
866
867    fn http_bearer_token(
868        &self,
869        repo_path: &Path,
870        repository_id: &str,
871        http_origin: &str,
872    ) -> Result<Option<String>, NapError> {
873        // repository_descriptor and manifest hydration already exercised Lore's
874        // authenticated transport, which refreshes repository-scoped credentials.
875        let identity = LoreProcessRunner::run(
876            ["auth", "info", "--json", "--non-interactive"],
877            Some(repo_path),
878        )?;
879        let user = parse_lore_event_data(&identity, "authUserInfo")
880            .and_then(|data| event_string(&data, "id"))
881            .map_err(|_| {
882                NapError::VcsError("No active Lore identity; run nap auth login".into())
883            })?;
884        let tokens = LoreProcessRunner::run(
885            [
886                "auth",
887                "list",
888                "--with-token",
889                "--json",
890                "--non-interactive",
891            ],
892            Some(repo_path),
893        )
894        .map_err(|_| {
895            NapError::VcsError(
896                "Could not read Lore repository credentials; run nap auth login".into(),
897            )
898        })?;
899        select_http_token(
900            &tokens,
901            repository_id,
902            &user,
903            http_origin,
904            chrono::Utc::now().timestamp_millis().max(0) as u64,
905        )
906    }
907
908    fn file_content_address_at_ref(
909        &self,
910        repo_path: &Path,
911        file_path: &str,
912        reference: &str,
913    ) -> Result<VcsContentAddress, NapError> {
914        let stdout = LoreProcessRunner::run(
915            [
916                "file",
917                "info",
918                file_path,
919                "--revision",
920                reference,
921                "--json",
922                "--non-interactive",
923            ],
924            Some(repo_path),
925        )?;
926        let data = parse_lore_event_data(&stdout, "fileInfo")
927            .map_err(|e| NapError::VcsError(format!("failed to parse Lore file info: {e}")))?;
928        if data.get("isFile").and_then(serde_json::Value::as_bool) != Some(true) {
929            return Err(NapError::VcsError(format!(
930                "representation path '{file_path}' is not a file at revision '{reference}'"
931            )));
932        }
933        let hash = event_string(&data, "hash")
934            .map_err(|e| NapError::VcsError(format!("failed to parse Lore file info: {e}")))?;
935        let context = event_string(&data, "context")
936            .map_err(|e| NapError::VcsError(format!("failed to parse Lore file info: {e}")))?;
937        validate_lower_hex(&hash, 32, "file hash")
938            .and_then(|_| validate_lower_hex(&context, 16, "file context"))
939            .map_err(|e| NapError::VcsError(format!("failed to parse Lore file info: {e}")))?;
940        Ok(VcsContentAddress { hash, context })
941    }
942
943    // ── file metadata ───────────────────────────────────────────────
944    fn file_metadata_at_ref(
945        &self,
946        repo_path: &Path,
947        file_path: &str,
948        reference: &str,
949    ) -> Result<Option<BTreeMap<String, String>>, NapError> {
950        let stdout = LoreProcessRunner::run(
951            [
952                "file",
953                "metadata",
954                "get",
955                file_path,
956                "--revision",
957                reference,
958                "--non-interactive",
959            ],
960            Some(repo_path),
961        )?;
962
963        if stdout.trim().is_empty() || stdout.trim() == "null" {
964            return Ok(None);
965        }
966
967        parse_metadata_output(&stdout)
968            .map(Some)
969            .map_err(|e| NapError::VcsError(format!("failed to parse lore file metadata: {e}")))
970    }
971
972    fn read_provenance_blob(&self, repo_path: &Path, address: &str) -> Result<String, NapError> {
973        let bytes = hydrate_lore_file(
974            repo_path,
975            [
976                "file".to_string(),
977                "write".to_string(),
978                "--address".to_string(),
979                address.to_string(),
980            ],
981            "provenance-blob",
982        )?;
983        String::from_utf8(bytes).map_err(|e| {
984            NapError::VcsError(format!(
985                "hydrated provenance blob {address} is not valid UTF-8: {e}"
986            ))
987        })
988    }
989
990    // ── log ──────────────────────────────────────────────────────────
991    fn log(
992        &self,
993        path: &Path,
994        _file: Option<&str>,
995        limit: usize,
996    ) -> Result<Vec<CommitInfo>, NapError> {
997        let limit_str = limit.to_string();
998        let args = vec!["history", &limit_str, "--non-interactive"];
999
1000        let stdout = LoreProcessRunner::run(&args, Some(path))?;
1001
1002        if stdout.trim().is_empty() {
1003            return Ok(Vec::new());
1004        }
1005
1006        // Parse plain text output. Each revision is a block:
1007        //   Revision  : N
1008        //   Signature : <hex>
1009        //   Branch    : <id>
1010        //   Date      : <date>
1011        //       <message>
1012        //   Creator   : <author>
1013        //   Committer : <author>
1014        let mut commits = Vec::new();
1015        let mut current_signature = String::new();
1016        let mut current_author = String::new();
1017        let mut current_message = String::new();
1018        let mut current_timestamp = String::new();
1019        let mut current_parent: Option<String> = None;
1020        let mut in_message = false;
1021
1022        for line in stdout.lines() {
1023            let trimmed = line.trim();
1024            if trimmed.starts_with("Signature :") || trimmed.starts_with("Signature:") {
1025                // Save previous commit if we have one.
1026                if !current_signature.is_empty() {
1027                    commits.push(CommitInfo {
1028                        id: std::mem::take(&mut current_signature),
1029                        parent: current_parent.take(),
1030                        author: std::mem::take(&mut current_author),
1031                        message: std::mem::take(&mut current_message),
1032                        timestamp: std::mem::take(&mut current_timestamp),
1033                    });
1034                }
1035                current_signature = trimmed
1036                    .strip_prefix("Signature :")
1037                    .or_else(|| trimmed.strip_prefix("Signature:"))
1038                    .unwrap_or("")
1039                    .trim()
1040                    .to_string();
1041                in_message = false;
1042            } else if trimmed.starts_with("Date      :") || trimmed.starts_with("Date:") {
1043                current_timestamp = trimmed
1044                    .split_once(':')
1045                    .map(|(_, v)| v.trim().to_string())
1046                    .unwrap_or_default();
1047                in_message = true;
1048            } else if trimmed.starts_with("Creator   :") || trimmed.starts_with("Creator:") {
1049                current_author = trimmed
1050                    .split_once(':')
1051                    .map(|(_, v)| v.trim().to_string())
1052                    .unwrap_or_default();
1053                in_message = false;
1054            } else if trimmed.starts_with("Revision  :")
1055                || trimmed.starts_with("Revision:")
1056                || trimmed.starts_with("Branch    :")
1057                || trimmed.starts_with("Branch:")
1058                || trimmed.starts_with("Committer :")
1059                || trimmed.starts_with("Committer:")
1060            {
1061                in_message = false;
1062            } else if in_message {
1063                if trimmed.is_empty() || trimmed == "Commit succeeded" {
1064                    in_message = false;
1065                } else {
1066                    if !current_message.is_empty() {
1067                        current_message.push('\n');
1068                    }
1069                    current_message.push_str(trimmed);
1070                }
1071            }
1072        }
1073        // Push the last commit.
1074        if !current_signature.is_empty() {
1075            commits.push(CommitInfo {
1076                id: current_signature,
1077                parent: current_parent,
1078                author: current_author,
1079                message: current_message,
1080                timestamp: current_timestamp,
1081            });
1082        }
1083
1084        Ok(commits)
1085    }
1086
1087    // ── branching ────────────────────────────────────────────────────
1088    fn create_branch(&self, path: &Path, name: &str) -> Result<(), NapError> {
1089        LoreProcessRunner::run(["branch", "create", name, "--non-interactive"], Some(path))?;
1090        Ok(())
1091    }
1092
1093    fn switch_branch(&self, path: &Path, name: &str) -> Result<(), NapError> {
1094        LoreProcessRunner::run(["branch", "switch", name, "--non-interactive"], Some(path))?;
1095        Ok(())
1096    }
1097
1098    fn current_branch(&self, path: &Path) -> Result<String, NapError> {
1099        let stdout = LoreProcessRunner::run(["branch", "show", "--non-interactive"], Some(path))?;
1100        Ok(stdout.trim().to_string())
1101    }
1102
1103    fn list_branches(&self, path: &Path) -> Result<Vec<String>, NapError> {
1104        let stdout = LoreProcessRunner::run(["branch", "list", "--non-interactive"], Some(path))?;
1105        if stdout.is_empty() {
1106            return Ok(Vec::new());
1107        }
1108        // Parse plain text output:
1109        //   Local branches:
1110        //   * main
1111        //     feature-x
1112        //   Remote branches:
1113        //     main
1114        let mut branches = Vec::new();
1115        let mut in_local = false;
1116        for line in stdout.lines() {
1117            let trimmed = line.trim();
1118            if trimmed.starts_with("Local branches") {
1119                in_local = true;
1120                continue;
1121            }
1122            if trimmed.starts_with("Remote branches") {
1123                in_local = false;
1124                continue;
1125            }
1126            if in_local && !trimmed.is_empty() {
1127                // Strip "* " prefix for current branch marker.
1128                let name = trimmed.strip_prefix("* ").unwrap_or(trimmed);
1129                branches.push(name.to_string());
1130            }
1131        }
1132        Ok(branches)
1133    }
1134
1135    // ── head / revert ────────────────────────────────────────────────
1136    fn head_hash(&self, path: &Path) -> Result<String, NapError> {
1137        let stdout = LoreProcessRunner::run(["history", "1", "--non-interactive"], Some(path))?;
1138
1139        if stdout.trim().is_empty() {
1140            return Err(NapError::VcsError(
1141                "no commits in lore workspace".to_string(),
1142            ));
1143        }
1144
1145        // Parse "Signature : <hex>" from plain text output.
1146        stdout
1147            .lines()
1148            .find_map(|line| {
1149                line.trim()
1150                    .strip_prefix("Signature :")
1151                    .or_else(|| line.trim().strip_prefix("Signature:"))
1152            })
1153            .map(|s| s.trim().to_string())
1154            .ok_or_else(|| {
1155                NapError::VcsError(format!(
1156                    "failed to parse signature from lore history: {stdout}"
1157                ))
1158            })
1159    }
1160
1161    fn revert(&self, path: &Path, commit_hash: &str) -> Result<String, NapError> {
1162        let stdout = LoreProcessRunner::run(
1163            ["revision", "revert", commit_hash, "--non-interactive"],
1164            Some(path),
1165        )?;
1166        // Lore outputs: "Created revert revision <signature>"
1167        let signature = stdout
1168            .trim()
1169            .strip_prefix("Created revert revision ")
1170            .unwrap_or(stdout.trim());
1171        Ok(signature.to_string())
1172    }
1173
1174    fn resolve_branch_head(&self, path: &Path, branch: &str) -> Result<String, NapError> {
1175        let stdout = LoreProcessRunner::run(
1176            ["history", "1", "--branch", branch, "--non-interactive"],
1177            Some(path),
1178        )?;
1179
1180        if stdout.trim().is_empty() {
1181            return Err(NapError::VcsError(format!(
1182                "no commits found on branch '{branch}'"
1183            )));
1184        }
1185
1186        // Parse "Signature : <hex>" from plain text output.
1187        stdout
1188            .lines()
1189            .find_map(|line| {
1190                line.trim()
1191                    .strip_prefix("Signature :")
1192                    .or_else(|| line.trim().strip_prefix("Signature:"))
1193            })
1194            .map(|s| s.trim().to_string())
1195            .ok_or_else(|| {
1196                NapError::VcsError(format!(
1197                    "failed to parse signature from lore history on branch '{branch}': {stdout}"
1198                ))
1199            })
1200    }
1201
1202    // ── remotes ──────────────────────────────────────────────────────
1203    // Lore 0.8.4-portals.x has no `lore repository add/remove` — store remotes
1204    // locally in `.lore/remotes.toml` (simple, robust, extensible; not via lore CLI).
1205    fn add_remote(&self, path: &Path, name: &str, url: &str) -> Result<(), NapError> {
1206        let remotes_path = path.join(".lore").join("remotes.toml");
1207        let mut map: std::collections::BTreeMap<String, String> = if remotes_path.exists() {
1208            let content = std::fs::read_to_string(&remotes_path).unwrap_or_default();
1209            toml::from_str(&content).unwrap_or_default()
1210        } else {
1211            std::collections::BTreeMap::new()
1212        };
1213        map.insert(name.to_string(), url.to_string());
1214        if let Some(parent) = remotes_path.parent() {
1215            std::fs::create_dir_all(parent).map_err(|e| NapError::VcsError(e.to_string()))?;
1216        }
1217        let content = toml::to_string(&map).map_err(|e| NapError::VcsError(e.to_string()))?;
1218        std::fs::write(&remotes_path, content).map_err(|e| NapError::VcsError(e.to_string()))?;
1219        Ok(())
1220    }
1221
1222    fn remove_remote(&self, path: &Path, name: &str) -> Result<(), NapError> {
1223        let remotes_path = path.join(".lore").join("remotes.toml");
1224        if !remotes_path.exists() {
1225            return Ok(());
1226        }
1227        let content = std::fs::read_to_string(&remotes_path).unwrap_or_default();
1228        let mut map: std::collections::BTreeMap<String, String> =
1229            toml::from_str(&content).unwrap_or_default();
1230        map.remove(name);
1231        let new_content = toml::to_string(&map).map_err(|e| NapError::VcsError(e.to_string()))?;
1232        std::fs::write(&remotes_path, new_content)
1233            .map_err(|e| NapError::VcsError(e.to_string()))?;
1234        Ok(())
1235    }
1236
1237    fn list_remotes(&self, path: &Path) -> Result<Vec<(String, String)>, NapError> {
1238        let remotes_path = path.join(".lore").join("remotes.toml");
1239        if !remotes_path.exists() {
1240            return Ok(Vec::new());
1241        }
1242        let content = std::fs::read_to_string(&remotes_path).unwrap_or_default();
1243        let map: std::collections::BTreeMap<String, String> =
1244            toml::from_str(&content).unwrap_or_default();
1245        Ok(map.into_iter().collect())
1246    }
1247
1248    // ── push / pull ──────────────────────────────────────────────────
1249    fn push(
1250        &self,
1251        path: &Path,
1252        _remote: Option<&str>,
1253        branch: Option<&str>,
1254    ) -> Result<(), NapError> {
1255        // Resolve the branch name: prefer the caller-supplied value,
1256        // fall back to the workspace's current branch, then "main".
1257        let branch_name = match branch {
1258            Some(b) => b.to_string(),
1259            None => self
1260                .current_branch(path)
1261                .unwrap_or_else(|_| "main".to_string()),
1262        };
1263
1264        // Push branch via lore CLI (handles blob upload + branch tip advancement internally)
1265        let args = vec![
1266            "branch",
1267            "push",
1268            &branch_name,
1269            "--fast-forward-merge",
1270            "--non-interactive",
1271        ];
1272        LoreProcessRunner::run(&args, Some(path))?;
1273
1274        Ok(())
1275    }
1276
1277    fn pull(
1278        &self,
1279        path: &Path,
1280        _remote: Option<&str>,
1281        _branch: Option<&str>,
1282    ) -> Result<(), NapError> {
1283        // Sync via lore CLI (handles remote checking + blob download internally)
1284        let args = vec!["sync", "--non-interactive", "--reset"];
1285        LoreProcessRunner::run(&args, Some(path))?;
1286
1287        Ok(())
1288    }
1289}
1290
1291// ---------------------------------------------------------------------------
1292// Tests
1293// ---------------------------------------------------------------------------
1294
1295#[cfg(test)]
1296mod structured_output_tests {
1297    use super::*;
1298
1299    #[test]
1300    fn http_credentials_are_scoped_to_identity_repository_domain_and_expiry() {
1301        let token = |repo: &str, user: &str, domain: &str, expires: u64| {
1302            serde_json::json!({
1303                "tagName": "authIdentity", "data": { "resource": repo, "userId": user,
1304                    "authorizedDomains": domain, "expires": expires, "token": "test-secret" }
1305            })
1306            .to_string()
1307        };
1308        let origin = "https://lore.portals.works";
1309        assert_eq!(
1310            select_http_token(
1311                &token("repo", "alice", "portals.works", 2000),
1312                "repo",
1313                "alice",
1314                origin,
1315                1000
1316            )
1317            .unwrap()
1318            .as_deref(),
1319            Some("test-secret")
1320        );
1321        for event in [
1322            token("", "alice", "portals.works", 2000),
1323            token("other", "alice", "portals.works", 2000),
1324            token("repo", "bob", "portals.works", 2000),
1325            token("repo", "alice", "", 2000),
1326            token("repo", "alice", "other.test", 2000),
1327            token("repo", "alice", "portals.works", 500),
1328        ] {
1329            assert!(
1330                select_http_token(&event, "repo", "alice", origin, 1000)
1331                    .unwrap()
1332                    .is_none()
1333            );
1334        }
1335        assert!(
1336            select_http_token(
1337                &token("repo", "alice", "portals.works", 2000),
1338                "repo",
1339                "alice",
1340                "https://evilportals.works",
1341                1000
1342            )
1343            .unwrap()
1344            .is_none()
1345        );
1346    }
1347
1348    #[test]
1349    fn parses_repository_and_file_events_independently() {
1350        let repository = concat!(
1351            "{\"tagName\":\"repositoryData\",\"data\":{",
1352            "\"id\":\"0123456789abcdef0123456789abcdef\",",
1353            "\"remoteUrl\":\"lore://localhost:41337/repo\"}}\n",
1354            "{\"tagName\":\"complete\",\"data\":{}}"
1355        );
1356        let file = concat!(
1357            "{\"tagName\":\"fileInfo\",\"data\":{",
1358            "\"hash\":\"9753abf79e5aef60bd95ab76c1e5a14d01239beb37ff9897b6af8e040eb2413a\",",
1359            "\"context\":\"fedcba9876543210fedcba9876543210\",\"isFile\":true}}"
1360        );
1361        let repository_data = parse_lore_event_data(repository, "repositoryData").unwrap();
1362        let file_data = parse_lore_event_data(file, "fileInfo").unwrap();
1363        assert_eq!(
1364            event_string(&repository_data, "id").unwrap(),
1365            "0123456789abcdef0123456789abcdef"
1366        );
1367        assert_eq!(
1368            event_string(&file_data, "context").unwrap(),
1369            "fedcba9876543210fedcba9876543210"
1370        );
1371    }
1372
1373    #[test]
1374    fn rejects_duplicate_or_malformed_events() {
1375        let duplicate =
1376            "{\"tagName\":\"fileInfo\",\"data\":{}}\n{\"tagName\":\"fileInfo\",\"data\":{}}";
1377        assert!(parse_lore_event_data(duplicate, "fileInfo").is_err());
1378        assert!(parse_lore_event_data("not-json", "fileInfo").is_err());
1379        assert!(validate_lower_hex("abc", 16, "context").is_err());
1380    }
1381
1382    #[test]
1383    fn working_tree_binary_reads_are_lossless() {
1384        let temp = tempfile::TempDir::new().unwrap();
1385        let bytes = [0_u8, 0xff, 0x42];
1386        std::fs::write(temp.path().join("asset.bin"), bytes).unwrap();
1387        let backend = LoreBackend::from_env();
1388        assert_eq!(
1389            backend
1390                .read_file_bytes_at_ref(temp.path(), "asset.bin", None)
1391                .unwrap(),
1392            bytes
1393        );
1394        assert!(
1395            backend
1396                .read_file_at_ref(temp.path(), "asset.bin", None)
1397                .unwrap_err()
1398                .to_string()
1399                .contains("read_file_bytes_at_ref")
1400        );
1401    }
1402}
1403
1404#[cfg(all(test, feature = "lore-integration"))]
1405mod tests {
1406    use super::*;
1407
1408    // ---- LoreProcessRunner tests ---------------------------------------
1409
1410    #[test]
1411    fn test_binary_default() {
1412        assert_eq!(LoreProcessRunner::binary(), "lore");
1413    }
1414
1415    #[test]
1416    fn test_binary_from_env() {
1417        temp_env::with_var("NAPLORE_CLI", Some("/custom/lore"), || {
1418            assert_eq!(LoreProcessRunner::binary(), "/custom/lore");
1419        });
1420    }
1421
1422    #[test]
1423    fn test_run_captures_stdout() {
1424        // We can't test a real `lore` call in CI without the binary.
1425        // This test verifies the runner returns an error for a missing
1426        // binary, which confirms the process-spawning path works.
1427        temp_env::with_var("NAPLORE_CLI", Some("lore-nonexistent-binary-12345"), || {
1428            let result = LoreProcessRunner::run(["--version"], None);
1429            assert!(result.is_err());
1430            let err = result.unwrap_err().to_string();
1431            assert!(
1432                err.contains("lore-nonexistent-binary-12345"),
1433                "error: {}",
1434                err
1435            );
1436        });
1437    }
1438
1439    // ---- LoreBackend tests --------------------------------------------
1440
1441    #[test]
1442    fn test_new_and_from_env() {
1443        let backend = LoreBackend::new("lore://myhost:8700", "test-workspace");
1444        assert_eq!(backend.remote_url, "lore://myhost:8700");
1445        assert_eq!(backend.workspace_id, "test-workspace");
1446
1447        temp_env::with_vars(
1448            vec![
1449                ("NAP_LORE_URL_BASE", Some("lore://custom:9999")),
1450                ("NAP_WORKSPACE_ID", Some("custom-ws")),
1451            ],
1452            || {
1453                let from_env = LoreBackend::from_env();
1454                assert_eq!(from_env.remote_url, "lore://custom:9999");
1455                assert_eq!(from_env.workspace_id, "custom-ws");
1456            },
1457        );
1458    }
1459
1460    #[test]
1461    fn test_from_env_default_without_env_vars() {
1462        // Test default behavior when no env vars are set and no provider config exists
1463        let temp_dir = tempfile::TempDir::new().unwrap();
1464        let nap_dir_str = temp_dir.path().to_str().unwrap();
1465
1466        temp_env::with_vars(
1467            vec![
1468                ("NAP_LORE_URL_BASE", None::<&str>),
1469                ("NAP_WORKSPACE_ID", None::<&str>),
1470                ("NAP_DIR", Some(nap_dir_str)),
1471            ],
1472            || {
1473                let backend = LoreBackend::from_env();
1474                assert_eq!(backend.remote_url, "lore://localhost:41337");
1475                assert_eq!(backend.workspace_id, "default");
1476            },
1477        );
1478    }
1479
1480    #[test]
1481    fn test_from_env_env_var_override() {
1482        // Test that env vars take precedence over provider config
1483        let temp_dir = tempfile::TempDir::new().unwrap();
1484        let nap_dir_str = temp_dir.path().to_str().unwrap();
1485
1486        temp_env::with_vars(
1487            vec![
1488                ("NAP_LORE_URL_BASE", Some("lore://override:1234")),
1489                ("NAP_WORKSPACE_ID", Some("override-ws")),
1490                ("NAP_DIR", Some(nap_dir_str)),
1491            ],
1492            || {
1493                let backend = LoreBackend::from_env();
1494                assert_eq!(backend.remote_url, "lore://override:1234");
1495                assert_eq!(backend.workspace_id, "override-ws");
1496            },
1497        );
1498    }
1499
1500    #[test]
1501    fn test_from_env_partial_env_override() {
1502        // Test partial env var override (only URL set, workspace defaults)
1503        let temp_dir = tempfile::TempDir::new().unwrap();
1504        let nap_dir_str = temp_dir.path().to_str().unwrap();
1505
1506        temp_env::with_vars(
1507            vec![
1508                ("NAP_LORE_URL_BASE", Some("lore://partial:5678")),
1509                ("NAP_WORKSPACE_ID", None::<&str>),
1510                ("NAP_DIR", Some(nap_dir_str)),
1511            ],
1512            || {
1513                let backend = LoreBackend::from_env();
1514                assert_eq!(backend.remote_url, "lore://partial:5678");
1515                assert_eq!(backend.workspace_id, "default");
1516            },
1517        );
1518    }
1519
1520    #[test]
1521    fn test_from_env_provider_config() {
1522        // Test provider config reading when env vars are not set
1523        let temp_dir = tempfile::TempDir::new().unwrap();
1524        let provider_config = temp_dir.path().join("provider.toml");
1525        std::fs::write(
1526            &provider_config,
1527            r#"
1528provider_type = "remote"
1529remote_url = "lore://provider:9999"
1530workspace_id = "provider-ws"
1531"#,
1532        )
1533        .unwrap();
1534
1535        let nap_dir_str = temp_dir.path().to_str().unwrap();
1536        temp_env::with_vars(
1537            vec![
1538                ("NAP_LORE_URL_BASE", None::<&str>),
1539                ("NAP_WORKSPACE_ID", None::<&str>),
1540                ("NAP_DIR", Some(nap_dir_str)),
1541            ],
1542            || {
1543                let backend = LoreBackend::from_env();
1544                assert_eq!(backend.remote_url, "lore://provider:9999");
1545                assert_eq!(backend.workspace_id, "provider-ws");
1546            },
1547        );
1548    }
1549
1550    #[test]
1551    fn test_from_env_nap_dir_with_tilde() {
1552        // Test NAP_DIR with ~ expansion
1553        let _home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
1554        let temp_dir = tempfile::TempDir::new().unwrap();
1555        let nap_dir_str = temp_dir.path().to_str().unwrap();
1556
1557        temp_env::with_vars(
1558            vec![
1559                ("NAP_LORE_URL_BASE", None::<&str>),
1560                ("NAP_WORKSPACE_ID", None::<&str>),
1561                ("NAP_DIR", Some(nap_dir_str)),
1562            ],
1563            || {
1564                let backend = LoreBackend::from_env();
1565                // Should use defaults since provider config doesn't exist
1566                assert_eq!(backend.remote_url, "lore://localhost:41337");
1567                assert_eq!(backend.workspace_id, "default");
1568            },
1569        );
1570    }
1571
1572    #[test]
1573    fn test_from_env_local_provider_config() {
1574        // Test local provider configuration
1575        let temp_dir = tempfile::TempDir::new().unwrap();
1576        let provider_config = temp_dir.path().join("provider.toml");
1577        std::fs::write(
1578            &provider_config,
1579            r#"
1580provider_type = "local"
1581"#,
1582        )
1583        .unwrap();
1584
1585        let nap_dir_str = temp_dir.path().to_str().unwrap();
1586        temp_env::with_vars(
1587            vec![
1588                ("NAP_LORE_URL_BASE", None::<&str>),
1589                ("NAP_WORKSPACE_ID", None::<&str>),
1590                ("NAP_DIR", Some(nap_dir_str)),
1591            ],
1592            || {
1593                let backend = LoreBackend::from_env();
1594                assert_eq!(backend.remote_url, "lore://localhost:41337");
1595                assert_eq!(backend.workspace_id, "default");
1596            },
1597        );
1598    }
1599
1600    #[test]
1601    fn test_from_env_portals_cloud_provider_config() {
1602        // Test portals-cloud provider configuration
1603        let temp_dir = tempfile::TempDir::new().unwrap();
1604        let provider_config = temp_dir.path().join("provider.toml");
1605        std::fs::write(
1606            &provider_config,
1607            r#"
1608provider_type = "portals-cloud"
1609workspace_id = "cloud-ws"
1610"#,
1611        )
1612        .unwrap();
1613
1614        let nap_dir_str = temp_dir.path().to_str().unwrap();
1615        temp_env::with_vars(
1616            vec![
1617                ("NAP_LORE_URL_BASE", None::<&str>),
1618                ("NAP_WORKSPACE_ID", None::<&str>),
1619                ("NAP_DIR", Some(nap_dir_str)),
1620            ],
1621            || {
1622                let backend = LoreBackend::from_env();
1623                assert_eq!(backend.remote_url, PORTALS_CLOUD_URL);
1624                assert_eq!(backend.workspace_id, "cloud-ws");
1625            },
1626        );
1627    }
1628
1629    #[test]
1630    fn test_from_env_portals_cloud_default_workspace() {
1631        // Test portals-cloud with default workspace
1632        let temp_dir = tempfile::TempDir::new().unwrap();
1633        let provider_config = temp_dir.path().join("provider.toml");
1634        std::fs::write(
1635            &provider_config,
1636            r#"
1637provider_type = "portals-cloud"
1638"#,
1639        )
1640        .unwrap();
1641
1642        let nap_dir_str = temp_dir.path().to_str().unwrap();
1643        temp_env::with_vars(
1644            vec![
1645                ("NAP_LORE_URL_BASE", None::<&str>),
1646                ("NAP_WORKSPACE_ID", None::<&str>),
1647                ("NAP_DIR", Some(nap_dir_str)),
1648            ],
1649            || {
1650                let backend = LoreBackend::from_env();
1651                assert_eq!(backend.remote_url, PORTALS_CLOUD_URL);
1652                assert_eq!(backend.workspace_id, "default");
1653            },
1654        );
1655    }
1656
1657    #[test]
1658    fn test_from_env_unknown_provider_type() {
1659        // Test unknown provider type falls back to defaults
1660        let temp_dir = tempfile::TempDir::new().unwrap();
1661        let provider_config = temp_dir.path().join("provider.toml");
1662        std::fs::write(
1663            &provider_config,
1664            r#"
1665provider_type = "unknown-provider"
1666"#,
1667        )
1668        .unwrap();
1669
1670        let nap_dir_str = temp_dir.path().to_str().unwrap();
1671        temp_env::with_vars(
1672            vec![
1673                ("NAP_LORE_URL_BASE", None::<&str>),
1674                ("NAP_WORKSPACE_ID", None::<&str>),
1675                ("NAP_DIR", Some(nap_dir_str)),
1676            ],
1677            || {
1678                let backend = LoreBackend::from_env();
1679                assert_eq!(backend.remote_url, "lore://localhost:41337");
1680                assert_eq!(backend.workspace_id, "default");
1681            },
1682        );
1683    }
1684
1685    #[test]
1686    fn test_repo_url_joining() {
1687        let backend = LoreBackend::new("lore://localhost:8700", "ws");
1688        assert_eq!(backend.repo_url("my-repo"), "lore://localhost:8700/my-repo");
1689
1690        // With trailing slash.
1691        let backend2 = LoreBackend::new("lore://host:8700/", "ws");
1692        assert_eq!(backend2.repo_url("foo"), "lore://host:8700/foo");
1693    }
1694
1695    #[test]
1696    fn test_list_branches_empty_json() {
1697        // Verify the edge case guards work for empty/bogus stdout.
1698        // The `[]` and `null` branches of `list_branches` are tested
1699        // through unit coverage of the deserialisation logic in `log`.
1700        // edge-case guards checked in production code
1701    }
1702
1703    #[test]
1704    fn test_commit_parses_signature_from_stdout() {
1705        // We can't call the real commit, but we can check the stdout
1706        // parse path is wired in: the `commit` impl extracts the first
1707        // whitespace token after "Created revision ".
1708        let sample = "Created revision a1b2c3d4 (#42)";
1709        let signature = sample
1710            .strip_prefix("Created revision ")
1711            .and_then(|s| s.split_whitespace().next())
1712            .unwrap_or(sample);
1713        assert_eq!(signature, "a1b2c3d4");
1714    }
1715
1716    // ---- CommitInfo from_lore_revision test -------------------------
1717
1718    #[test]
1719    fn test_commit_info_from_lore_revision() {
1720        let info = CommitInfo::from_lore_revision(
1721            "sig123",
1722            Some("sig122"),
1723            "alice",
1724            "feat: add manifest",
1725            "2026-06-30T12:00:00Z",
1726        );
1727        assert_eq!(info.id, "sig123");
1728        assert_eq!(info.parent.as_deref(), Some("sig122"));
1729        assert_eq!(info.author, "alice");
1730        assert_eq!(info.message, "feat: add manifest");
1731        assert_eq!(info.timestamp, "2026-06-30T12:00:00Z");
1732    }
1733
1734    #[test]
1735    fn test_commit_info_default_timestamp() {
1736        // When timestamp is empty, we expect an RFC 3339 timestamp.
1737        let info = CommitInfo::from_lore_revision("sig", None, "bob", "msg", "");
1738        assert!(
1739            info.timestamp.contains('T') || info.timestamp.contains('Z'),
1740            "expected RFC 3339 timestamp, got: {}",
1741            info.timestamp
1742        );
1743    }
1744}