nap-core 0.8.9

Core library for the Narrative Addressing Protocol
Documentation
// SPDX-FileCopyrightText: 2026 Digital Creations
// SPDX-License-Identifier: MIT
//! Lore server configuration generation
//!
//! Generates local.toml configuration for Lore server with platform-independent paths
//! and recommended persistent defaults for NAP-managed deployments.

use anyhow::{Context, Result};
use rand::RngCore;
use std::fs;
use std::io::Write;
use std::path::Path;

/// Generate Lore server configuration for local deployment
///
/// Creates a complete local.toml configuration file with:
/// - Persistent stores under NAP home directory
/// - Persistent certificates
/// - Recommended defaults for local development
pub fn generate_local_config(nap_home: &Path) -> Result<ConfigFiles> {
    let lore_config_dir = nap_home.join("lore").join("config");
    let local_toml_path = lore_config_dir.join("local.toml");

    fs::create_dir_all(&lore_config_dir).context("Failed to create Lore config directory")?;

    // Only regenerate if missing
    if local_toml_path.exists() {
        let content = fs::read_to_string(&local_toml_path)?;
        let mut config: toml::Value = toml::from_str(&content)?;
        if let Some(http) = config
            .get_mut("server")
            .and_then(|v| v.get_mut("http"))
            .and_then(toml::Value::as_table_mut)
            && http.get("enabled").and_then(toml::Value::as_bool) != Some(false)
            && !http.contains_key("presigned_url_hmac_key")
        {
            http.insert("presigned_url_hmac_key".into(), new_presign_key().into());
            let mut file = tempfile::NamedTempFile::new_in(&lore_config_dir)?;
            file.write_all(toml::to_string_pretty(&config)?.as_bytes())?;
            file.persist(&local_toml_path)?;
        }
        tracing::info!("Lore config already exists at {:?}", local_toml_path);
        return Ok(ConfigFiles {
            config_path: local_toml_path,
            config_dir: lore_config_dir,
        });
    }

    tracing::info!("Generating Lore server configuration");

    let config = generate_config_toml(nap_home);
    let mut options = fs::OpenOptions::new();
    options.write(true).create_new(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        options.mode(0o600);
    }
    let mut file = options
        .open(&local_toml_path)
        .context("Failed to create Lore configuration file")?;
    file.write_all(config.as_bytes())
        .context("Failed to write Lore configuration file")?;

    tracing::info!("Lore configuration generated at {:?}", local_toml_path);

    Ok(ConfigFiles {
        config_path: local_toml_path,
        config_dir: lore_config_dir,
    })
}

fn new_presign_key() -> String {
    let mut key = [0u8; 32];
    rand::rng().fill_bytes(&mut key);
    hex::encode(key)
}

/// Generate the TOML configuration content
fn generate_config_toml(nap_home: &Path) -> String {
    let immutable_path = nap_home.join("lore").join("store").join("immutable");
    let mutable_path = nap_home.join("lore").join("store").join("mutable");
    let _cert_path = nap_home.join("lore").join("certs").join("cert.pem");
    let _key_path = nap_home.join("lore").join("certs").join("key.pem");
    let presign_key = new_presign_key();
    let immutable_path_toml = toml_quote(&immutable_path);
    let mutable_path_toml = toml_quote(&mutable_path);

    format!(
        r#"
# =============================================================================
# Lore Server Configuration (Generated by NAP SDK)
# =============================================================================
# This configuration is automatically generated by the NAP SDK.
# Manual modifications may be overwritten.

[server]
connection_close_timeout_seconds = 5
runtime_shutdown_timeout_seconds = 25

# Public facing QUIC server settings
[server.quic]
enabled = true
host = "127.0.0.1"
port = 41337
verify_client_certs = false
idle_timeout = 30_000
keep_alive = 500
max_bidi_streams = 8
num_listeners = 10
transport_bits_per_second = 1_073_741_824  # 1 gbit/s
transport_rtt = 1  # 1 ms for local development (was 100ms)
handler_timeout_seconds = 50

# gRPC server settings
[server.grpc]
enabled = true
host = "127.0.0.1"
port = 41337
request_handler_timeout_seconds = 50
verify_client_certs = false

# HTTP server settings
[server.http]
enabled = true
host = "127.0.0.1"
port = 41339
max_file_size = 10_485_760  # 10MB
request_timeout_seconds = 300
request_body_timeout_seconds = 3600
available_interval_seconds = 30
available_timeout_seconds = 5
store_health_check = false
presigned_url_hmac_key = "{}"

# =============================================================================
# Store Configuration
# =============================================================================

[immutable_store]
mode = "local"

[immutable_store.local]
path = "{}"
flush_delay_seconds = 0

[mutable_store]
mode = "local"

[mutable_store.local]
path = "{}"
flush_delay_seconds = 0

[lock_store]
mode = "local"

# =============================================================================
# Tokio Runtime Configuration
# =============================================================================

[tokio]
max_blocking_threads = 512

# =============================================================================
# Telemetry Configuration
# =============================================================================

[telemetry.logger]
enable_otlp = false
format = "json"
output = "stdout"

[telemetry.metrics]
export_interval_millis = 30000
sample_interval_millis = 10000

[telemetry.traces]
sample_rate = 0.05
sample_rate_low_tier = 0.001

# =============================================================================
# Notification Configuration
# =============================================================================

[notification]
mode = "local"

# =============================================================================
# Other Features Configuration
# =============================================================================

[feature]
history_step_size = 100
"#,
        presign_key, immutable_path_toml, mutable_path_toml
    )
}

/// Encode a filesystem path as a TOML basic string. Windows paths contain
/// backslashes, which must be escaped or generated configuration is invalid.
fn toml_quote(path: &Path) -> String {
    path.to_string_lossy()
        .replace('\\', "\\\\")
        .replace('"', "\\\"")
        .replace('\n', "\\n")
        .replace('\r', "\\r")
        .replace('\t', "\\t")
}

/// Paths to generated configuration files
#[derive(Debug, Clone)]
pub struct ConfigFiles {
    pub config_path: std::path::PathBuf,
    pub config_dir: std::path::PathBuf,
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[test]
    fn upgrade_adds_one_stable_key_and_preserves_existing_settings() {
        let dir = TempDir::new().unwrap();
        let config = dir.path().join("lore/config");
        fs::create_dir_all(&config).unwrap();
        let path = config.join("local.toml");
        fs::write(
            &path,
            "[server.http]\nenabled = true\nport = 4242\n[custom]\nvalue = 7\n",
        )
        .unwrap();
        generate_local_config(dir.path()).unwrap();
        let first = fs::read_to_string(&path).unwrap();
        generate_local_config(dir.path()).unwrap();
        assert_eq!(fs::read_to_string(&path).unwrap(), first);
        assert!(first.contains("port = 4242"));
        assert!(first.contains("value = 7"));
        let doc: toml::Value = toml::from_str(&first).unwrap();
        assert_eq!(
            doc["server"]["http"]["presigned_url_hmac_key"]
                .as_str()
                .unwrap()
                .len(),
            64
        );
    }

    #[test]
    fn test_generate_local_config() {
        let temp_dir = TempDir::new().unwrap();
        let nap_home = temp_dir.path();

        let files = generate_local_config(nap_home).unwrap();

        assert!(files.config_path.exists());
        assert!(files.config_dir.exists());

        // Verify configuration content
        let content = fs::read_to_string(&files.config_path).unwrap();
        assert!(content.contains("[server.quic]"));
        assert!(content.contains("port = 41337"));
        assert!(content.contains("[immutable_store.local]"));
        assert!(content.contains("[mutable_store.local]"));
        let metadata = fs::metadata(&files.config_path).unwrap();
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
        }

        // Verify we can regenerate without error (should skip if exists)
        let files2 = generate_local_config(nap_home).unwrap();
        assert_eq!(files.config_path, files2.config_path);
    }

    #[test]
    fn test_config_toml_content() {
        let temp_dir = TempDir::new().unwrap();
        let nap_home = temp_dir.path();

        let config = generate_config_toml(nap_home);

        // Verify key configuration sections
        assert!(config.contains("[server.quic]"));
        assert!(config.contains("port = 41337"));
        assert!(config.contains("[server.grpc]"));
        assert!(config.contains("[server.http]"));
        assert!(config.contains("port = 41339"));
        let key = config
            .lines()
            .find_map(|line| {
                line.strip_prefix("presigned_url_hmac_key = \"")?
                    .strip_suffix('"')
            })
            .unwrap();
        assert_eq!(key.len(), 64);
        assert!(key.bytes().all(|byte| byte.is_ascii_hexdigit()));
        assert!(config.contains("[immutable_store]"));
        assert!(config.contains("mode = \"local\""));
        assert!(config.contains("[mutable_store]"));
        assert!(config.contains("[lock_store]"));
        assert!(config.contains("[telemetry.logger]"));
        assert!(config.contains("[telemetry.metrics]"));
        assert!(config.contains("[notification]"));
        assert!(config.contains("[feature]"));

        // Verify paths round-trip through TOML decoding. Comparing the raw
        // rendered text is incorrect on Windows because backslashes must be
        // escaped in TOML basic strings.
        let parsed: toml::Value = toml::from_str(&config).unwrap();
        assert_eq!(
            parsed["immutable_store"]["local"]["path"].as_str(),
            nap_home
                .join("lore")
                .join("store")
                .join("immutable")
                .to_str()
        );
        assert_eq!(
            parsed["mutable_store"]["local"]["path"].as_str(),
            nap_home.join("lore").join("store").join("mutable").to_str()
        );

        // Windows paths must remain valid TOML (notably `\\U` is a Unicode
        // escape in TOML and must be encoded as `\\\\U`).
        let windows_path =
            Path::new(r"C:\Users\RUNNER~1\AppData\Local\Temp\.tmp123\lore\store\immutable");
        let windows_config = config.replace(
            &toml_quote(&nap_home.join("lore").join("store").join("immutable")),
            &toml_quote(windows_path),
        );
        toml::from_str::<toml::Value>(&windows_config).unwrap();
    }
}