//! Rust crate generator for McPact capability servers.

pub mod hosts;

use mcpact_manifest::{ArgSpec, ArgType, Manifest, ToolSpec};
use sha2::{Digest, Sha256};
use std::{
    fs,
    path::{Path, PathBuf},
};
use thiserror::Error;

/// Generation options.
#[derive(Debug, Clone)]
pub struct GenerateOptions {
    /// Output directory.
    pub out_dir: PathBuf,
    /// How generated `Cargo.toml` should reference McPact crates.
    ///
    /// Default is [`DependencySource::CratesIoPinned`] — the G-1 quality-gate
    /// shape. Customers running `cargo install <generated-mcp>` from
    /// crates.io get version-pinned deps and an opt-in
    /// `[patch.crates-io]` block for local-dev work. The legacy
    /// [`DependencySource::WorkspacePath`] mode is preserved for the
    /// in-tree golden-tests and developers regenerating examples from a
    /// source checkout, but it still carries version pins so generated
    /// crates never contain path-only mcpact sibling deps.
    pub dependency_source: DependencySource,
    /// Path from generated crate to the McPact workspace root. Used only
    /// when `dependency_source == WorkspacePath`. Defaults to `../..`.
    pub mcpact_workspace_path: String,
    /// Source manifest path, used only to record its hash in `.mcpact/mcpact.lock`.
    pub source_manifest_path: Option<PathBuf>,
    /// Whether to write `.mcpact/mcpact.lock`. Enabled by default.
    pub write_lockfile: bool,
}

impl GenerateOptions {
    /// Create options with out dir.
    ///
    /// Defaults to [`DependencySource::CratesIoPinned`] — the G-1
    /// portability-gate-conformant shape. Override via field assignment
    /// for legacy workspace-path generation in tests or local dev.
    #[must_use]
    pub fn new(out_dir: impl Into<PathBuf>) -> Self {
        Self {
            out_dir: out_dir.into(),
            dependency_source: DependencySource::CratesIoPinned,
            mcpact_workspace_path: "../..".into(),
            source_manifest_path: None,
            write_lockfile: true,
        }
    }
}

/// How generated `Cargo.toml` should reference the mcpact crates.
///
/// G-1 (standalone-portability) anchor:
/// `ecosystem-catalog/standardisation/gates/portability-audit-2026-05-20.md`.
/// A v0.2 customer running `cargo install <generated-mcp>` from
/// crates.io must not need an mcpact source checkout. `CratesIoPinned`
/// is the default for that reason.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DependencySource {
    /// Version-pinned crates.io deps (e.g. `mcpact-core = "0.3.0"`, tracking
    /// the workspace version via `PUBLISHED_VERSION_REQ`) plus a
    /// commented-out `[patch.crates-io]` block that developers can
    /// uncomment to point at a local mcpact checkout. **G-1 default.**
    CratesIoPinned,
    /// Version-pinned crates.io deps, no `[patch.crates-io]` block.
    /// Used by published-crate downstream consumers that do not need a
    /// dev-loop. Emits exactly what a v0.2 customer would expect.
    CratesIo,
    /// Local path-dependencies rooted at `mcpact_workspace_path`, with
    /// matching crates.io version requirements. The in-tree golden
    /// tests and example-adapter regeneration use this. Still only
    /// valid for local source-checkout builds; do NOT make this the
    /// default.
    WorkspacePath,
}

impl DependencySource {
    /// True when this mode emits any `path = ...` line that anchors on
    /// `mcpact_workspace_path` (i.e., requires an mcpact source checkout).
    #[must_use]
    pub const fn is_workspace_path(self) -> bool {
        matches!(self, Self::WorkspacePath)
    }
}

/// The crates.io version requirement emitted for every mcpact-* dep
/// when `DependencySource::CratesIo{,Pinned}` is selected.
///
/// Tracks the workspace version (`CARGO_PKG_VERSION`, inherited via
/// `version.workspace = true`) so generated customer-facing crates always
/// pin the same version the mcpact crates are actually published at. This
/// is deliberately NOT a hand-maintained literal: a previous workspace bump
/// (0.2.0 -> 0.3.0, the BUSL relicense) left this at "0.2.0", so generated
/// adapters requested `^0.2.0` and failed to resolve against the published
/// 0.3.0 crates. Binding it to the build-time version makes that desync
/// impossible. The gate test
/// `cargo_toml_default_has_no_unpinned_sibling_path_deps` fails-closed if
/// this constant is ever set back to a path-only or unpinned emission.
pub const PUBLISHED_VERSION_REQ: &str = env!("CARGO_PKG_VERSION");

/// Generation error.
#[derive(Debug, Error)]
pub enum GenerateError {
    /// I/O failed.
    #[error("generation I/O failed: {0}")]
    Io(#[from] std::io::Error),
    /// Manifest invalid.
    #[error("manifest invalid: {0}")]
    Invalid(String),
}

/// Typed lockfile drift classes for `verify_lock`.
#[derive(Debug, PartialEq, Eq)]
pub enum LockfileDrift {
    /// Source manifest SHA-256 does not match lockfile.
    ManifestHash,
    /// Runtime wrapper version does not match lockfile.
    RuntimeVersion,
    /// `tool-definitions.schema.json` SHA-256 does not match lockfile.
    ToolDefinitionsSchema,
    /// `authority.schema.json` SHA-256 does not match lockfile.
    AuthoritySchema,
    /// A generated file is missing from lockfile or its hash changed.
    FileHash(String),
    /// Lockfile missing or unreadable.
    LockfileMissing,
    /// Lockfile schema version unknown.
    BadSchema,
}

impl std::fmt::Display for LockfileDrift {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            LockfileDrift::ManifestHash => write!(f, "source manifest hash does not match lockfile (LockfileDrift::ManifestHash)"),
            LockfileDrift::RuntimeVersion => write!(f, "runtime wrapper version does not match lockfile (LockfileDrift::RuntimeVersion)"),
            LockfileDrift::ToolDefinitionsSchema => write!(f, "tool-definitions.schema.json hash does not match lockfile (LockfileDrift::ToolDefinitionsSchema)"),
            LockfileDrift::AuthoritySchema => write!(f, "authority.schema.json hash does not match lockfile (LockfileDrift::AuthoritySchema)"),
            LockfileDrift::FileHash(path) => write!(f, "lockfile missing entry or hash mismatch for {path} (LockfileDrift::FileHash)"),
            LockfileDrift::LockfileMissing => write!(f, "lockfile missing or unreadable (LockfileDrift::LockfileMissing)"),
            LockfileDrift::BadSchema => write!(f, "unsupported lockfile schema (LockfileDrift::BadSchema)"),
        }
    }
}

/// The mcpact-runtime crate version embedded into generated lockfiles for drift detection.
pub const RUNTIME_WRAPPER_VERSION: &str = env!("CARGO_PKG_VERSION");

pub use hosts::{
    emit_command_host_configs, emit_host_configs, HostCommandConfig, HostEmitError,
    HostEmitOptions, HostId,
};

/// Render `tool-definitions.schema.json` — JSON Schema of the tool definitions object.
pub fn render_tool_definitions_schema(manifest: &Manifest) -> Result<String, GenerateError> {
    // Build a JSON Schema object where each key is a tool name and value is the tool's schema.
    let mut properties: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
    let mut required: Vec<serde_json::Value> = Vec::new();
    for tool in sorted_tools(manifest) {
        let tool_schema =
            serde_json::to_value(tool).map_err(|err| GenerateError::Invalid(err.to_string()))?;
        properties.insert(tool.name.clone(), tool_schema);
        required.push(serde_json::Value::String(tool.name.clone()));
    }
    let schema = serde_json::json!({
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "title": "McPact tool-definitions",
        "description": format!("Tool definitions for {} generated by mcpact-generate.", manifest.package.name),
        "type": "object",
        "properties": properties,
        "required": required,
        "additionalProperties": false,
    });
    serde_json::to_string_pretty(&schema).map_err(|err| GenerateError::Invalid(err.to_string()))
}

/// Render `authority.schema.json` — JSON Schema of `AuthorityClass` and `TrustCeiling`.
///
/// The `AuthorityClass` enum names come from
/// [`mcpact_core::AuthorityClass::CANONICAL_NAMES`] so the emitted schema
/// cannot drift from the Rust enum. G-2 schema-cohesion anchor:
/// `ecosystem-catalog/standardisation/gates/portability-audit-2026-05-20.md` §4.
pub fn render_authority_schema() -> Result<String, GenerateError> {
    let authority_enum: Vec<serde_json::Value> = mcpact_core::AuthorityClass::CANONICAL_NAMES
        .iter()
        .map(|name| serde_json::Value::String((*name).to_string()))
        .collect();
    let schema = serde_json::json!({
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "title": "McPact authority schema",
        "description": "Defines AuthorityClass and TrustCeiling enums used in McPact manifests. \
                        The canonical Analyze variant is sometimes spoken as \"Derive\"; only \
                        \"Analyze\" is accepted on the wire.",
        "definitions": {
            "AuthorityClass": {
                "title": "AuthorityClass",
                "description": "Authority class for a generated MCP tool. Canonical 6-variant \
                                ladder: Observe < Analyze < Plan < Mutate < Destroy < Credential.",
                "type": "string",
                "enum": authority_enum,
            },
            "TrustCeiling": {
                "title": "TrustCeiling",
                "description": "Trust ceiling for a tool spec.",
                "type": "string",
                "enum": ["Inferred", "Reviewed", "Signed", "Verified"]
            }
        }
    });
    serde_json::to_string_pretty(&schema).map_err(|err| GenerateError::Invalid(err.to_string()))
}

/// Generate a Rust MCP server crate from a manifest.
pub fn generate_crate(manifest: &Manifest, opts: &GenerateOptions) -> Result<(), GenerateError> {
    manifest
        .validate_strict()
        .map_err(|e| GenerateError::Invalid(e.to_string()))?;
    let mut opts = opts.clone();
    // Workspace-path normalization is only meaningful when we'll actually
    // emit `path = ...` deps. For crates.io modes the workspace path is
    // never written to the generated Cargo.toml, so we skip the lookup.
    let workspace_path = Path::new(&opts.mcpact_workspace_path);
    if opts.dependency_source.is_workspace_path()
        && (is_default_workspace_path(&opts.mcpact_workspace_path) || workspace_path.is_absolute())
    {
        opts.mcpact_workspace_path =
            normalize_workspace_path(&opts.out_dir, &opts.mcpact_workspace_path);
    }
    let out = &opts.out_dir;
    fs::create_dir_all(out.join("src/tools"))?;
    fs::create_dir_all(out.join(".mcpact/tools"))?;
    write(out.join("Cargo.toml"), render_cargo_toml(manifest, &opts))?;
    write(out.join("README.md"), render_readme(manifest))?;
    write(
        out.join("src/server_config.rs"),
        render_server_config(manifest),
    )?;
    write(out.join("src/main.rs"), render_main(manifest))?;
    write(out.join("src/tools/mod.rs"), render_tools_mod(manifest))?;
    for tool in sorted_tools(manifest) {
        let spec_json =
            serde_json::to_string(tool).map_err(|e| GenerateError::Invalid(e.to_string()))?;
        write(
            out.join(".mcpact/tools")
                .join(format!("{}.json", tool.name)),
            spec_json,
        )?;
    }
    for tool in sorted_tools(manifest) {
        write(
            out.join("src/tools").join(format!("{}.rs", tool.name)),
            render_tool(manifest, tool),
        )?;
    }
    if let Some(source) = &opts.source_manifest_path {
        let dest = out.join(".mcpact/source.mcpact.toml");
        if let Some(parent) = dest.parent() {
            fs::create_dir_all(parent)?;
        }
        fs::copy(source, &dest)?;
    }
    emit_host_configs(manifest, &HostEmitOptions::for_generated_tree(out), out)
        .map_err(|err| GenerateError::Invalid(err.to_string()))?;
    // Emit canonical schemas.
    fs::create_dir_all(out.join(".mcpact/schemas"))?;
    write(
        out.join(".mcpact/schemas/tool-definitions.schema.json"),
        render_tool_definitions_schema(manifest)?,
    )?;
    write(
        out.join(".mcpact/schemas/authority.schema.json"),
        render_authority_schema()?,
    )?;
    if opts.write_lockfile {
        write_generation_lock(manifest, &opts)?;
    }
    Ok(())
}

fn write(path: impl AsRef<Path>, content: String) -> Result<(), GenerateError> {
    let normalized = content.replace("\r\n", "\n");
    fs::write(path, normalized)?;
    Ok(())
}

/// The set of mcpact-* crates a generated MCP server depends on.
///
/// Kept as a separate `const` because both the dep-emitter and the
/// `[patch.crates-io]` opt-in block iterate over the same list.
const MCPACT_DEPS: &[&str] = &[
    "mcpact-core",
    "mcpact-runtime",
    "mcpact-audit",
    "mcpact-policy",
    "mcpact-mcp",
    "mcpact-manifest",
];

fn render_cargo_toml(manifest: &Manifest, opts: &GenerateOptions) -> String {
    let mcpact_deps = render_mcpact_dependencies(opts);
    let patch_block = render_patch_crates_io_block(opts);
    format!(
        r#"[package]
name = "{name}"
version = "{version}"
edition = "2021"
license = "AGPL-3.0-or-later"
description = "{description}"

[workspace]

[dependencies]
async-trait = "0.1"
schemars = {{ version = "0.8", features = ["derive"] }}
serde = {{ version = "1", features = ["derive"] }}
serde_json = "1"
tokio = {{ version = "1", features = ["macros", "rt-multi-thread"] }}
toml = "0.8"
sha2 = "0.10"
{mcpact_deps}
{patch_block}"#,
        name = manifest.package.name,
        version = manifest.package.version,
        description = manifest.package.description,
        mcpact_deps = mcpact_deps,
        patch_block = patch_block,
    )
}

/// Emit the `mcpact-* = ...` dependency lines according to
/// [`GenerateOptions::dependency_source`].
fn render_mcpact_dependencies(opts: &GenerateOptions) -> String {
    match opts.dependency_source {
        DependencySource::CratesIo | DependencySource::CratesIoPinned => MCPACT_DEPS
            .iter()
            .map(|name| format!("{name} = \"{PUBLISHED_VERSION_REQ}\""))
            .collect::<Vec<_>>()
            .join("\n"),
        DependencySource::WorkspacePath => {
            let workspace_path = opts.mcpact_workspace_path.replace('\\', "/");
            MCPACT_DEPS
                .iter()
                .map(|name| {
                    format!(
                        "{name} = {{ path = \"{workspace_path}/crates/{name}\", version = \"{PUBLISHED_VERSION_REQ}\" }}"
                    )
                })
                .collect::<Vec<_>>()
                .join("\n")
        }
    }
}

/// Emit the opt-in `[patch.crates-io]` block (commented out by default).
///
/// `CratesIoPinned` mode emits the block in a commented form so a
/// developer can uncomment to point at a local mcpact checkout without
/// editing the version-pinned deps above. `CratesIo` and `WorkspacePath`
/// both emit no block.
fn render_patch_crates_io_block(opts: &GenerateOptions) -> String {
    if !matches!(opts.dependency_source, DependencySource::CratesIoPinned) {
        return String::new();
    }
    let mut lines: Vec<String> = vec![
        String::new(),
        "# Dev-only — uncomment to develop against a local mcpact checkout.".into(),
        "# Customers installing via `cargo install` should leave this block".into(),
        "# commented out (the crates.io deps above are the canonical source).".into(),
        "# G-1 portability gate: this block MUST stay commented in shipped".into(),
        "# artifacts. See ecosystem-catalog/standardisation/gates/".into(),
        "# portability-audit-2026-05-20.md.".into(),
        "# [patch.crates-io]".into(),
    ];
    for name in MCPACT_DEPS {
        lines.push(format!(
            "# {name} = {{ path = \"../mcpact/crates/{name}\", version = \"{PUBLISHED_VERSION_REQ}\" }}"
        ));
    }
    let mut block = lines.join("\n");
    block.push('\n');
    block
}

fn render_readme(manifest: &Manifest) -> String {
    format!(
        r#"# {}

Generated by McPact.

Source CLI: `{}`
Trust ceiling: `{:?}`

## Serve

```bash
cargo run -- serve
```

## Tools

{}
"#,
        manifest.package.name,
        manifest.cli.binary,
        manifest.package.trust,
        sorted_tools(manifest)
            .iter()
            .map(|t| format!("- `{}` — {}", t.name, t.description))
            .collect::<Vec<_>>()
            .join("\n")
    )
}

fn render_server_config(manifest: &Manifest) -> String {
    let trust = format!("{:?}", manifest.package.trust);
    let audit_sink = escape(&manifest.audit.sink);
    let audit_url = match manifest
        .audit
        .url
        .as_ref()
        .filter(|url| !url.trim().is_empty())
    {
        Some(url) => format!("Some(\"{}\")", escape(url)),
        None => "None".to_string(),
    };
    format!(
        r#"//! Generated server configuration.

use std::path::{{Path, PathBuf}};
use std::sync::OnceLock;

pub static RESOLVED_BINARY: OnceLock<PathBuf> = OnceLock::new();

const SOURCE_MANIFEST: &str = include_str!(concat!("../.mcpact/source.mcpact.toml"));

fn lock_source_manifest_sha256(lock: &str) -> Option<String> {{
    lock.lines().find_map(|line| {{
        line.strip_prefix("source_manifest_sha256 = \"")
            .and_then(|rest| rest.strip_suffix('"'))
            .map(str::to_string)
    }})
}}

fn sha256_hex(bytes: &[u8]) -> String {{
    use sha2::{{Digest, Sha256}};
    Sha256::digest(bytes)
        .iter()
        .map(|byte| format!("{{byte:02x}}"))
        .collect()
}}

/// Verify signed/verified trust and lockfile before serving.
pub fn verify_pack_integrity() -> Result<(), String> {{
    let manifest: mcpact_manifest::Manifest = toml::from_str(SOURCE_MANIFEST)
        .map_err(|err| format!("embedded manifest parse failed: {{err}}"))?;
    use mcpact_core::TrustCeiling;
    if matches!(
        manifest.package.trust,
        TrustCeiling::Signed | TrustCeiling::Verified
    ) {{
        mcpact_manifest::verify_package_signature_trusted(&manifest)
            .map_err(|err| format!("serve-time signature verification failed: {{err}}"))?;
    }}
    let embedded_lock = include_str!(concat!("../.mcpact/mcpact.lock"));
    if !embedded_lock.contains("schema_version = \"mcpact.lock.v1\"") {{
        return Err("invalid embedded lockfile".into());
    }}
    let on_disk = std::fs::read_to_string(".mcpact/mcpact.lock")
        .map_err(|err| format!("lockfile unreadable: {{err}}"))?;
    if embedded_lock != on_disk {{
        return Err("embedded lockfile does not match on-disk copy (tree tampered)".into());
    }}
    if let Some(expected) = lock_source_manifest_sha256(embedded_lock) {{
        let actual = sha256_hex(SOURCE_MANIFEST.as_bytes());
        if actual != expected {{
            return Err("embedded manifest hash does not match lockfile".into());
        }}
    }}
    Ok(())
}}

/// Resolve and cache the CLI binary path.
pub fn init(binary_name: &str) -> Result<(), String> {{
    verify_pack_integrity()?;
    let resolved = mcpact_runtime::resolve_binary(binary_name).map_err(|err| err.to_string())?;
    RESOLVED_BINARY
        .set(resolved)
        .map_err(|_| "server binary already initialized".to_string())
}}

/// Resolved binary path for execution plans.
pub fn binary_path() -> &'static Path {{
    RESOLVED_BINARY.get().expect("server binary was not resolved at startup")
}}

/// Manifest trust ceiling.
pub const TRUST: mcpact_core::TrustCeiling = mcpact_core::TrustCeiling::{trust};

/// Manifest `[audit].sink` (ADR-0027).
pub const AUDIT_SINK: &str = "{audit_sink}";

/// Manifest `[audit].url` when set.
pub const AUDIT_CLOUDEVENTS_URL: Option<&str> = {audit_url};

/// Resolved JSONL audit path from manifest (supports `audit.xdg_state`).
pub fn audit_jsonl_path() -> PathBuf {{
    let manifest: mcpact_manifest::Manifest = toml::from_str(SOURCE_MANIFEST)
        .expect("embedded manifest must parse");
    mcpact_manifest::resolve_audit_jsonl_path(&manifest.audit, &manifest.package.name)
}}

/// Runtime audit sinks from manifest settings.
pub fn audit_sink() -> mcpact_audit::MultiAuditSink {{
    mcpact_audit::MultiAuditSink::from_manifest_settings(
        AUDIT_SINK,
        audit_jsonl_path(),
        AUDIT_CLOUDEVENTS_URL,
    )
}}
"#,
    )
}

fn render_main(manifest: &Manifest) -> String {
    let registers = sorted_tools(manifest)
        .iter()
        .map(|t| format!("    registry.register(tools::{}::Tool::new());", t.name))
        .collect::<Vec<_>>()
        .join("\n");
    let version_probe = if manifest.cli.version_command.is_empty() {
        String::new()
    } else {
        let args = manifest
            .cli
            .version_command
            .iter()
            .map(|arg| format!("\"{}\".to_string()", escape(arg)))
            .collect::<Vec<_>>()
            .join(", ");
        // Route the version probe through the no-shell Executor so it inherits
        // reject_shell_tokens, env_clear, timeout, and the output cap — keeping
        // the all-dispatch-uses-the-runtime invariant and avoiding raw
        // std::process::Command::new in generated trees.
        format!(
            "    {{\n        let probe = mcpact_runtime::ExecutionPlan::new(server_config::binary_path().to_string_lossy().to_string()).args([{args}]);\n        let _ = mcpact_runtime::Executor::default().execute(probe).await;\n    }}\n"
        )
    };
    format!(
        r#"//! Generated McPact MCP server crate. Do not edit generated code directly.

mod server_config;
mod tools;

use mcpact_mcp::ToolRegistry;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {{
    server_config::init("{binary}")?;
{version_probe}
    let mut registry = ToolRegistry::new();
{registers}
    mcpact_mcp::serve_stdio(registry).await?;
    Ok(())
}}
"#,
        binary = escape(&manifest.cli.binary),
    )
}

fn render_tools_mod(manifest: &Manifest) -> String {
    sorted_tools(manifest)
        .iter()
        .map(|t| format!("pub mod {};", t.name))
        .collect::<Vec<_>>()
        .join("\n")
        + "\n"
}

fn render_tool(manifest: &Manifest, tool: &ToolSpec) -> String {
    let struct_name = format!("{}Args", pascal(&tool.name));
    let fields = tool
        .args
        .iter()
        .map(|(name, spec)| render_arg_field(name, spec))
        .collect::<Vec<_>>()
        .join("\n");
    let argv_build = render_argv_build(tool);
    let redacted_setup = render_redacted_setup(tool);
    let env_setup = render_env_plan(tool);
    let cwd_setup = manifest
        .cli
        .default_cwd
        .as_ref()
        .map(|cwd| {
            format!(
                "        plan.cwd = Some(std::path::PathBuf::from(\"{}\"));",
                escape(cwd.as_str())
            )
        })
        .unwrap_or_default();
    let output_mode = match tool.output.mode.as_str() {
        "json" => "mcpact_runtime::OutputMode::Json",
        "sarif" => "mcpact_runtime::OutputMode::Sarif",
        _ => "mcpact_runtime::OutputMode::Text",
    };
    let annotations = format!(
        "mcpact_mcp::ToolDefinition::mcpact_annotations(mcpact_core::AuthorityClass::{}, server_config::TRUST)",
        tool.policy.authority
    );
    let tool_spec_path = format!(".mcpact/tools/{}.json", tool.name);
    format!(
        r#"//! Generated tool `{tool_name}`.

use crate::server_config;
use async_trait::async_trait;
use schemars::JsonSchema;
use serde::{{Deserialize, Serialize}};
use serde_json::json;
use mcpact_mcp::{{McpTool, ToolCallResult, ToolDefinition}};
use mcpact_runtime::{{ExecutionPlan, Executor}};
use mcpact_audit::AuditSink;
use std::collections::BTreeSet;

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct {struct_name} {{
{fields}
}}

#[derive(Debug, Clone, Default)]
pub struct Tool;

impl Tool {{
    pub fn new() -> Self {{ Self }}
}}

#[async_trait]
impl McpTool for Tool {{
    fn definition(&self) -> ToolDefinition {{
        let schema = schemars::schema_for!({struct_name});
        ToolDefinition {{
            name: "{tool_name}".into(),
            title: {title},
            description: "{description}".into(),
            input_schema: serde_json::to_value(&schema).unwrap_or_else(|_| json!({{"type":"object"}})),
            output_schema: None,
            annotations: Some({annotations}),
        }}
    }}

    async fn call(&self, arguments: serde_json::Value) -> ToolCallResult {{
        let args: {struct_name} = match serde_json::from_value(arguments) {{
            Ok(args) => args,
            Err(err) => return ToolCallResult::error(format!("invalid arguments: {{err}}")),
        }};

        let tool_spec: mcpact_manifest::ToolSpec = match serde_json::from_str(include_str!(concat!("../../{tool_spec_path}"))) {{
            Ok(spec) => spec,
            Err(err) => return ToolCallResult::error(format!("tool spec load failed: {{err}}")),
        }};
        let args_json = match serde_json::to_value(&args) {{
            Ok(value) => value,
            Err(err) => return ToolCallResult::error(format!("argument serialization failed: {{err}}")),
        }};
        let workspace = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
        // Operator grants secret/env access at runtime via MCPACT_ALLOWED_SECRETS
        // (comma/whitespace-separated names). Without it, tools whose env.allow
        // names a secret-looking variable are denied — fail closed, but with a
        // real grant mechanism (parity with MCPACT_APPROVED).
        let allowed_secrets: BTreeSet<String> = std::env::var("MCPACT_ALLOWED_SECRETS")
            .ok()
            .map(|raw| {{
                raw.split([',', ' ', '\t', ';', ':', '\n'])
                    .map(str::trim)
                    .filter(|s| !s.is_empty())
                    .map(|s| s.to_string())
                    .collect()
            }})
            .unwrap_or_default();
        let ctx = mcpact_policy::PolicyContext {{
            workspace_root: workspace,
            trust_ceiling: server_config::TRUST,
            approved: std::env::var("MCPACT_APPROVED").is_ok_and(|value| value == "1"),
            allow_network: tool_spec.policy.network,
            allowed_secrets,
        }};
        let policy_outcome = match mcpact_policy::evaluate_invocation_full(&ctx, &tool_spec, &args_json) {{
            Ok(outcome) if outcome.decision.allowed => outcome,
            Ok(outcome) => {{
                let reason = outcome.decision.reason.clone();
                let event = mcpact_audit::EvidenceEvent::tool_denied(
                    "{tool_name}",
                    mcpact_core::AuthorityClass::{authority},
                    &reason,
                );
                let sink = server_config::audit_sink();
                let _ = sink.emit(&event).await;
                return ToolCallResult::error(reason);
            }}
            Err(err) => {{
                let event = mcpact_audit::EvidenceEvent::tool_denied(
                    "{tool_name}",
                    mcpact_core::AuthorityClass::{authority},
                    err.to_string(),
                );
                let sink = server_config::audit_sink();
                let _ = sink.emit(&event).await;
                return ToolCallResult::error(err.to_string());
            }}
        }};
        // Canonicalized, workspace-jailed path values from policy validation.
        // Substituted into argv below so the executed value is the validated
        // path, not the raw user string (closes the check-then-use TOCTOU gap).
        #[allow(unused_variables)]
        let canonical_paths = policy_outcome.canonical_paths;

        let mut plan = ExecutionPlan::new(server_config::binary_path().to_string_lossy().to_string());
        plan.argv = Vec::new();
{argv_build}
{redacted_setup}
{env_setup}
{cwd_setup}
        plan.timeout = std::time::Duration::from_secs({timeout});
        plan.max_output_bytes = {max_bytes};
        plan.output_mode = {output_mode};
        plan.authority = mcpact_core::AuthorityClass::{authority};

        let plan_for_audit = plan.clone();
        match Executor::default().execute(plan).await {{
            Ok(result) => {{
                let event = mcpact_audit::EvidenceEvent::tool_executed("{tool_name}", &plan_for_audit, &result);
                let sink = server_config::audit_sink();
                let _ = sink.emit(&event).await;
                if let Some(value) = result.structured {{
                    ToolCallResult::structured(value)
                }} else if {expose_stderr} && !result.stderr.is_empty() {{
                    ToolCallResult::text(format!("{{}}\n{{}}", result.stdout, result.stderr))
                }} else {{
                    ToolCallResult::text(result.stdout)
                }}
            }}
            Err(err) => {{
                let event = mcpact_audit::EvidenceEvent::tool_failed(
                    "{tool_name}",
                    &plan_for_audit,
                    err.to_string(),
                );
                let sink = server_config::audit_sink();
                let _ = sink.emit(&event).await;
                ToolCallResult::error(format!("execution failed: {{err}}"))
            }}
        }}
    }}
}}
"#,
        tool_name = tool.name,
        title = tool
            .title
            .as_ref()
            .map(|t| format!("Some(\"{}\".into())", escape(t)))
            .unwrap_or_else(|| "None".into()),
        description = escape(&tool.description),
        timeout = tool.policy.timeout_seconds,
        max_bytes = tool.output.max_bytes,
        authority = tool.policy.authority,
        expose_stderr = tool.output.expose_stderr,
    )
}

fn render_arg_field(name: &str, spec: &ArgSpec) -> String {
    let ty = match spec.arg_type {
        ArgType::Bool => "bool".to_string(),
        ArgType::Integer if spec.required => "i64".to_string(),
        ArgType::Integer => "Option<i64>".to_string(),
        ArgType::Number if spec.required => "f64".to_string(),
        ArgType::Number => "Option<f64>".to_string(),
        ArgType::Json if spec.required => "serde_json::Value".to_string(),
        ArgType::Json => "Option<serde_json::Value>".to_string(),
        _ if spec.required => "String".to_string(),
        _ => "Option<String>".to_string(),
    };
    let default_attr = if spec.required {
        ""
    } else {
        "    #[serde(default)]\n"
    };
    format!("{default_attr}    pub {name}: {ty},")
}

fn render_env_plan(tool: &ToolSpec) -> String {
    let mut lines = vec![format!("        plan.env.inherit = {};", tool.env.inherit)];
    if !tool.env.allow.is_empty() {
        let allowed = tool
            .env
            .allow
            .iter()
            .map(|key| format!("\"{}\".into()", escape(key)))
            .collect::<Vec<_>>()
            .join(", ");
        lines.push(format!("        plan.env.allow = vec![{allowed}];"));
    }
    for (key, value) in &tool.env.set {
        lines.push(format!(
            "        plan.env.set.insert(\"{}\".into(), \"{}\".into());",
            escape(key),
            escape(value)
        ));
    }
    lines.join("\n")
}

fn render_redacted_setup(tool: &ToolSpec) -> String {
    let has_redact = tool.args.values().any(|spec| spec.redact);
    let mut lines = vec![if has_redact {
        "        let mut redacted = Vec::new();".to_string()
    } else {
        "        let redacted = Vec::new();".to_string()
    }];
    let mut idx = tool.command.len();
    for (name, spec) in sorted_args(tool) {
        if spec.redact {
            match spec.arg_type {
                ArgType::Bool => lines.push(format!(
                    "        if args.{name} {{ redacted.push({idx}); }}"
                )),
                _ if !spec.required => {
                    lines.push(format!("        if args.{name}.is_some() {{"));
                    let value_idx = if spec.flag.is_some() { idx + 1 } else { idx };
                    lines.push(format!("            redacted.push({value_idx});"));
                    lines.push("        }".into());
                }
                _ => lines.push(format!("        redacted.push({idx});")),
            }
        }
        idx += argv_slot_count(spec);
    }
    lines.push("        plan.redacted_arg_indexes = redacted;".into());
    lines.join("\n")
}

fn sorted_args(tool: &ToolSpec) -> Vec<(&String, &ArgSpec)> {
    let mut args = tool.args.iter().collect::<Vec<_>>();
    args.sort_by(|(left_name, left), (right_name, right)| {
        left.position
            .unwrap_or(u32::MAX)
            .cmp(&right.position.unwrap_or(u32::MAX))
            .then_with(|| left_name.cmp(right_name))
    });
    args
}

fn argv_slot_count(spec: &ArgSpec) -> usize {
    match spec.arg_type {
        ArgType::Bool => usize::from(spec.flag.is_some()),
        _ if spec.flag.is_some() => 2,
        _ => 1,
    }
}

fn render_argv_build(tool: &ToolSpec) -> String {
    let mut lines = Vec::new();
    for fixed in &tool.command {
        lines.push(format!(
            "        plan.argv.push(\"{}\".into());",
            escape(fixed)
        ));
    }
    let mut args = tool.args.iter().collect::<Vec<_>>();
    args.sort_by(|(left_name, left), (right_name, right)| {
        left.position
            .unwrap_or(u32::MAX)
            .cmp(&right.position.unwrap_or(u32::MAX))
            .then_with(|| left_name.cmp(right_name))
    });
    for (name, spec) in args {
        match spec.arg_type {
            ArgType::Bool => {
                if let Some(flag) = &spec.flag {
                    lines.push(format!(
                        "        if args.{name} {{ plan.argv.push(\"{}\".into()); }}",
                        escape(flag)
                    ));
                }
            }
            _ => {
                // For workspace-jailed / must_exist path args, prefer the
                // canonicalized value produced by policy validation over the raw
                // user string. This is the TOCTOU-closing substitution.
                let use_canonical = matches!(spec.arg_type, ArgType::Path)
                    && (spec.within_workspace || spec.must_exist);
                let base_value_expr = if spec.required {
                    format!("args.{name}.to_string()")
                } else if matches!(spec.arg_type, ArgType::Integer | ArgType::Number) {
                    format!(
                        "match args.{name} {{ Some(v) => v.to_string(), None => String::new() }}"
                    )
                } else {
                    format!("match args.{name} {{ Some(v) => v, None => String::new() }}")
                };
                let value_expr = if use_canonical {
                    format!(
                        "canonical_paths.get(\"{name}\").cloned().unwrap_or_else(|| {{ {base_value_expr} }})"
                    )
                } else {
                    base_value_expr
                };
                if !spec.required {
                    lines.push(format!("        if args.{name}.is_some() {{"));
                }
                if let Some(flag) = &spec.flag {
                    lines.push(format!(
                        "        plan.argv.push(\"{}\".into());",
                        escape(flag)
                    ));
                }
                lines.push(format!("        plan.argv.push({value_expr});"));
                if !spec.required {
                    lines.push("        }".into());
                }
            }
        }
    }
    lines.join("\n")
}

fn sorted_tools(manifest: &Manifest) -> Vec<&ToolSpec> {
    let mut tools = manifest.tools.iter().collect::<Vec<_>>();
    tools.sort_by(|left, right| left.name.cmp(&right.name));
    tools
}

fn write_generation_lock(manifest: &Manifest, opts: &GenerateOptions) -> Result<(), GenerateError> {
    let out = &opts.out_dir;
    fs::create_dir_all(out.join(".mcpact"))?;

    let mut files = Vec::new();
    collect_files(out, out, &mut files)?;
    files.sort_by(|left, right| left.0.cmp(&right.0));

    let source_hash = match &opts.source_manifest_path {
        Some(path) => Some(hash_file(path)?),
        None => None,
    };

    // Hash the two schema files from the output tree (they were just written above).
    let tool_def_schema_hash =
        hash_file(&out.join(".mcpact/schemas/tool-definitions.schema.json"))?;
    let authority_schema_hash = hash_file(&out.join(".mcpact/schemas/authority.schema.json"))?;

    let mut text = String::new();
    text.push_str("schema_version = \"mcpact.lock.v1\"\n");
    text.push_str(&format!(
        "package_name = \"{}\"\n",
        escape(&manifest.package.name)
    ));
    text.push_str(&format!(
        "package_version = \"{}\"\n",
        escape(&manifest.package.version)
    ));
    text.push_str(&format!(
        "generator = \"mcpact-generate {RUNTIME_WRAPPER_VERSION}\"\n"
    ));
    text.push_str(&format!(
        "runtime_wrapper_version = \"{RUNTIME_WRAPPER_VERSION}\"\n"
    ));
    text.push_str(&format!("trust = \"{:?}\"\n", manifest.package.trust));
    if let Some(hash) = source_hash {
        text.push_str(&format!("source_manifest_sha256 = \"{hash}\"\n"));
    }
    text.push_str(&format!(
        "tool_definitions_schema_sha256 = \"{tool_def_schema_hash}\"\n"
    ));
    text.push_str(&format!(
        "authority_schema_sha256 = \"{authority_schema_hash}\"\n"
    ));
    text.push_str("\n[[generated_files]]\n");

    let mut first = true;
    for (relative, absolute) in files {
        if relative == ".mcpact/mcpact.lock" {
            continue;
        }
        if !first {
            text.push_str("\n[[generated_files]]\n");
        }
        first = false;
        let bytes = fs::metadata(&absolute)?.len();
        let digest = hash_file(&absolute)?;
        text.push_str(&format!("path = \"{}\"\n", escape(&relative)));
        text.push_str(&format!("bytes = {bytes}\n"));
        text.push_str(&format!("sha256 = \"{digest}\"\n"));
    }

    write(out.join(".mcpact/mcpact.lock"), text)
}

fn collect_files(
    root: &Path,
    current: &Path,
    out: &mut Vec<(String, PathBuf)>,
) -> Result<(), GenerateError> {
    let mut entries = fs::read_dir(current)?.collect::<Result<Vec<_>, _>>()?;
    entries.sort_by_key(|entry| entry.path());
    for entry in entries {
        let path = entry.path();
        if path.is_dir() {
            collect_files(root, &path, out)?;
        } else if path.is_file() {
            let relative = path
                .strip_prefix(root)
                .unwrap_or(&path)
                .to_string_lossy()
                .replace('\\', "/");
            out.push((relative, path));
        }
    }
    Ok(())
}

fn hash_file(path: &Path) -> Result<String, GenerateError> {
    let bytes = fs::read(path)?;
    Ok(hex_sha256(&bytes))
}

fn is_volatile_generated_artifact(relative: &str) -> bool {
    relative == ".mcpact/audit.jsonl"
}

/// Verify a generated tree against its lockfile and optional source manifest.
///
/// Returns `Ok(())` on a clean match. On drift, returns a `GenerateError::Invalid` whose
/// message starts with the [`LockfileDrift`] variant name for programmatic inspection.
pub fn verify_lock(
    generated_dir: &Path,
    source_manifest: Option<&Path>,
) -> Result<(), GenerateError> {
    let lock_path = generated_dir.join(".mcpact/mcpact.lock");
    let lock_text = fs::read_to_string(&lock_path)
        .map_err(|_err| GenerateError::Invalid(LockfileDrift::LockfileMissing.to_string()))?;
    if !lock_text.contains("schema_version = \"mcpact.lock.v1\"") {
        return Err(GenerateError::Invalid(LockfileDrift::BadSchema.to_string()));
    }

    // Check source manifest hash.
    if let Some(manifest_path) = source_manifest {
        let expected = hash_file(manifest_path)?;
        let actual = lock_text
            .lines()
            .find_map(|line| line.strip_prefix("source_manifest_sha256 = \""))
            .and_then(|line| line.strip_suffix('"'))
            .ok_or_else(|| GenerateError::Invalid(LockfileDrift::ManifestHash.to_string()))?;
        if actual != expected {
            return Err(GenerateError::Invalid(
                LockfileDrift::ManifestHash.to_string(),
            ));
        }
    }

    // Check runtime wrapper version.
    {
        let locked_version = lock_text
            .lines()
            .find_map(|line| line.strip_prefix("runtime_wrapper_version = \""))
            .and_then(|line| line.strip_suffix('"'));
        if let Some(locked) = locked_version {
            if locked != RUNTIME_WRAPPER_VERSION {
                return Err(GenerateError::Invalid(
                    LockfileDrift::RuntimeVersion.to_string(),
                ));
            }
        }
    }

    // Check tool-definitions schema hash.
    {
        let schema_path = generated_dir.join(".mcpact/schemas/tool-definitions.schema.json");
        if schema_path.is_file() {
            let actual = hash_file(&schema_path)?;
            let locked = lock_text
                .lines()
                .find_map(|line| line.strip_prefix("tool_definitions_schema_sha256 = \""))
                .and_then(|line| line.strip_suffix('"'));
            if let Some(locked) = locked {
                if locked != actual {
                    return Err(GenerateError::Invalid(
                        LockfileDrift::ToolDefinitionsSchema.to_string(),
                    ));
                }
            }
        }
    }

    // Check authority schema hash.
    {
        let schema_path = generated_dir.join(".mcpact/schemas/authority.schema.json");
        if schema_path.is_file() {
            let actual = hash_file(&schema_path)?;
            let locked = lock_text
                .lines()
                .find_map(|line| line.strip_prefix("authority_schema_sha256 = \""))
                .and_then(|line| line.strip_suffix('"'));
            if let Some(locked) = locked {
                if locked != actual {
                    return Err(GenerateError::Invalid(
                        LockfileDrift::AuthoritySchema.to_string(),
                    ));
                }
            }
        }
    }

    // Check per-file hashes by parsing the lockfile into structured entries and
    // correlating path<->sha256 per entry. Substring matching (the previous
    // approach) could yield a false "no drift" when a tampered file's digest
    // happened to match some *other* entry's digest. Structured comparison ties
    // each path to its own recorded digest.
    let parsed: LockfileEntries = toml::from_str(&lock_text)
        .map_err(|_err| GenerateError::Invalid(LockfileDrift::BadSchema.to_string()))?;
    let mut locked: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new();
    for entry in parsed.generated_files {
        locked.insert(entry.path, entry.sha256);
    }

    let mut files = Vec::new();
    collect_files(generated_dir, generated_dir, &mut files)?;
    for (relative, absolute) in files {
        if relative == ".mcpact/mcpact.lock" || is_volatile_generated_artifact(&relative) {
            continue;
        }
        let digest = hash_file(&absolute)?;
        match locked.get(&relative) {
            Some(expected) if expected == &digest => {}
            _ => {
                return Err(GenerateError::Invalid(
                    LockfileDrift::FileHash(relative).to_string(),
                ));
            }
        }
    }
    Ok(())
}

/// Structured view of a lockfile's `[[generated_files]]` entries.
#[derive(Debug, serde::Deserialize)]
struct LockfileEntries {
    #[serde(default)]
    generated_files: Vec<LockfileEntry>,
}

/// One `[[generated_files]]` entry.
#[derive(Debug, serde::Deserialize)]
struct LockfileEntry {
    path: String,
    #[serde(default)]
    #[allow(dead_code)]
    bytes: u64,
    sha256: String,
}

fn hex_sha256(bytes: &[u8]) -> String {
    let digest = Sha256::digest(bytes);
    let mut out = String::with_capacity(digest.len() * 2);
    for byte in digest {
        out.push_str(&format!("{byte:02x}"));
    }
    out
}

fn pascal(s: &str) -> String {
    s.split('_')
        .filter(|p| !p.is_empty())
        .map(|p| {
            let mut chars = p.chars();
            match chars.next() {
                Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(),
                None => String::new(),
            }
        })
        .collect::<String>()
}

fn escape(s: &str) -> String {
    s.replace('\\', "\\\\").replace('"', "\\\"")
}

fn normalize_workspace_path(out_dir: &Path, workspace_path: &str) -> String {
    let workspace = Path::new(workspace_path);
    let requested_absolute = workspace.is_absolute();
    let workspace = if workspace.is_absolute() {
        workspace.to_path_buf()
    } else {
        std::env::current_dir()
            .unwrap_or_else(|_| PathBuf::from("."))
            .join(workspace)
    };
    let _ = fs::create_dir_all(out_dir);
    let out = out_dir
        .canonicalize()
        .unwrap_or_else(|_| out_dir.to_path_buf());
    let workspace = workspace.canonicalize().unwrap_or(workspace);
    if !requested_absolute && !out.starts_with(&workspace) {
        return workspace_path.to_string();
    }
    relative_path_from(&out, &workspace)
        .map(|path| path.to_string_lossy().replace('\\', "/"))
        .unwrap_or_else(|| workspace_path.to_string())
}

fn relative_path_from(base: &Path, target: &Path) -> Option<PathBuf> {
    let base_components: Vec<_> = base.components().collect();
    let target_components: Vec<_> = target.components().collect();
    let mut common = 0;
    while common < base_components.len()
        && common < target_components.len()
        && base_components[common] == target_components[common]
    {
        common += 1;
    }

    if common == 0 {
        return None;
    }

    let mut rel = PathBuf::new();
    for component in &base_components[common..] {
        if matches!(
            component,
            std::path::Component::Normal(_) | std::path::Component::CurDir
        ) {
            rel.push("..");
        }
    }
    for component in &target_components[common..] {
        rel.push(component.as_os_str());
    }
    if rel.as_os_str().is_empty() {
        rel.push(".");
    }
    Some(rel)
}

fn is_default_workspace_path(workspace_path: &str) -> bool {
    workspace_path.replace('\\', "/").trim_end_matches('/') == "../.."
}

#[cfg(test)]
mod tests {
    use super::*;
    use mcpact_manifest::Manifest;
    use std::fs;
    use tempfile::tempdir;

    const MANIFEST: &str = r#"
schema_version = "mcpact.manifest.v1"
[package]
name = "mcp-echo"
description = "echo adapter"
trust = "Reviewed"
[cli]
name = "echo"
binary = "echo"
[[tools]]
name = "echo_text"
description = "echo text"
command = []
[tools.args.text]
type = "string"
required = true
position = 1
[tools.policy]
authority = "Observe"
read_only = true
"#;

    #[test]
    fn generates_crate_files() {
        let manifest = Manifest::parse(MANIFEST).unwrap();
        let dir = tempdir().unwrap();
        generate_crate(&manifest, &GenerateOptions::new(dir.path())).unwrap();
        assert!(dir.path().join("Cargo.toml").exists());
        assert!(dir.path().join(".mcpact/mcpact.lock").exists());
        assert!(dir.path().join(".mcpact/hosts/cursor.json").exists());
        let tool = std::fs::read_to_string(dir.path().join("src/tools/echo_text.rs")).unwrap();
        assert!(tool.contains("evaluate_invocation"));
        assert!(tool.contains("server_config::binary_path()"));
        assert!(!tool.contains("Command::new(\"sh\")"));
    }

    #[test]
    fn generation_lock_is_stable_for_same_manifest() {
        let manifest = Manifest::parse(MANIFEST).unwrap();
        let first = tempdir().unwrap();
        let second = tempdir().unwrap();
        generate_crate(&manifest, &GenerateOptions::new(first.path())).unwrap();
        generate_crate(&manifest, &GenerateOptions::new(second.path())).unwrap();
        let first_lock = std::fs::read_to_string(first.path().join(".mcpact/mcpact.lock")).unwrap();
        let second_lock =
            std::fs::read_to_string(second.path().join(".mcpact/mcpact.lock")).unwrap();
        assert_eq!(first_lock, second_lock);
    }

    #[test]
    fn generates_all_example_adapters() {
        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../examples/adapters");
        for name in [
            "taudit",
            "cargo",
            "gh-readonly",
            "terraform-plan",
            "fixture-echo",
            "signed-fixture-echo",
            "verified-fixture-echo",
            "policy-gate",
            "bad-binary",
        ] {
            let manifest_path = root.join(name).join("mcpact.toml");
            let manifest = mcpact_manifest::Manifest::load(&manifest_path)
                .unwrap_or_else(|e| panic!("{name}: {e}"));
            let out = tempdir().unwrap();
            generate_crate(&manifest, &GenerateOptions::new(out.path())).unwrap();
            assert!(
                out.path().join("src/main.rs").exists(),
                "{name} missing main.rs"
            );
        }
    }

    #[test]
    fn taudit_generation_is_byte_stable() {
        let manifest_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("../../examples/adapters/taudit/mcpact.toml");
        let manifest = mcpact_manifest::Manifest::load(&manifest_path).unwrap();
        let first = tempdir().unwrap();
        let second = tempdir().unwrap();
        generate_crate(&manifest, &GenerateOptions::new(first.path())).unwrap();
        generate_crate(&manifest, &GenerateOptions::new(second.path())).unwrap();
        for file in ["Cargo.toml", "src/main.rs", "README.md"] {
            let a = std::fs::read(first.path().join(file)).unwrap();
            let b = std::fs::read(second.path().join(file)).unwrap();
            assert_eq!(a, b, "{file} not deterministic");
        }
    }

    // ── Fix 2 tests ──────────────────────────────────────────────────────────

    /// All canonical artifacts are emitted by `generate_crate`.
    #[test]
    fn mcpact_generate_emits_all_canonical_artifacts() {
        let manifest = Manifest::parse(MANIFEST).unwrap();
        let dir = tempdir().unwrap();
        let manifest_file = dir.path().join("mcpact.toml");
        fs::write(&manifest_file, MANIFEST).unwrap();
        let opts = GenerateOptions {
            out_dir: dir.path().join("generated"),
            dependency_source: DependencySource::WorkspacePath,
            mcpact_workspace_path: "../..".into(),
            source_manifest_path: Some(manifest_file),
            write_lockfile: true,
        };
        generate_crate(&manifest, &opts).unwrap();
        let out = &opts.out_dir;
        // Host registrations.
        assert!(
            out.join(".mcpact/hosts/cursor.json").is_file(),
            "missing cursor.json"
        );
        assert!(
            out.join(".mcpact/hosts/claude.json").is_file(),
            "missing claude.json"
        );
        assert!(
            out.join(".mcpact/hosts/codex.toml").is_file(),
            "missing codex.toml"
        );
        // Lockfile.
        assert!(
            out.join(".mcpact/mcpact.lock").is_file(),
            "missing mcpact.lock"
        );
        let lock = fs::read_to_string(out.join(".mcpact/mcpact.lock")).unwrap();
        assert!(
            lock.contains("schema_version = \"mcpact.lock.v1\""),
            "bad lock schema"
        );
        assert!(
            lock.contains("runtime_wrapper_version ="),
            "lock missing runtime_wrapper_version"
        );
        assert!(
            lock.contains("tool_definitions_schema_sha256 ="),
            "lock missing tool_definitions_schema_sha256"
        );
        assert!(
            lock.contains("authority_schema_sha256 ="),
            "lock missing authority_schema_sha256"
        );
        // Schemas.
        assert!(
            out.join(".mcpact/schemas/tool-definitions.schema.json")
                .is_file(),
            "missing tool-definitions schema"
        );
        assert!(
            out.join(".mcpact/schemas/authority.schema.json").is_file(),
            "missing authority schema"
        );
        // Schema contents are valid JSON.
        let tool_def =
            fs::read_to_string(out.join(".mcpact/schemas/tool-definitions.schema.json")).unwrap();
        serde_json::from_str::<serde_json::Value>(&tool_def)
            .expect("tool-definitions schema invalid JSON");
        let authority =
            fs::read_to_string(out.join(".mcpact/schemas/authority.schema.json")).unwrap();
        serde_json::from_str::<serde_json::Value>(&authority)
            .expect("authority schema invalid JSON");
    }

    /// `verify_lock` passes immediately after a clean `generate_crate`.
    #[test]
    fn mcpact_verify_lock_passes_after_clean_generate() {
        let manifest = Manifest::parse(MANIFEST).unwrap();
        let dir = tempdir().unwrap();
        let manifest_file = dir.path().join("mcpact.toml");
        fs::write(&manifest_file, MANIFEST).unwrap();
        let opts = GenerateOptions {
            out_dir: dir.path().join("generated"),
            dependency_source: DependencySource::WorkspacePath,
            mcpact_workspace_path: "../..".into(),
            source_manifest_path: Some(manifest_file.clone()),
            write_lockfile: true,
        };
        generate_crate(&manifest, &opts).unwrap();
        verify_lock(&opts.out_dir, Some(&manifest_file))
            .expect("verify_lock should pass after clean generate");
    }

    /// `verify_lock` returns `LockfileDrift::ManifestHash` when the manifest is tampered.
    #[test]
    fn mcpact_verify_lock_fails_on_manifest_drift() {
        let manifest = Manifest::parse(MANIFEST).unwrap();
        let dir = tempdir().unwrap();
        let manifest_file = dir.path().join("mcpact.toml");
        fs::write(&manifest_file, MANIFEST).unwrap();
        let opts = GenerateOptions {
            out_dir: dir.path().join("generated"),
            dependency_source: DependencySource::WorkspacePath,
            mcpact_workspace_path: "../..".into(),
            source_manifest_path: Some(manifest_file.clone()),
            write_lockfile: true,
        };
        generate_crate(&manifest, &opts).unwrap();
        // Tamper the manifest file on disk (not the lock).
        fs::write(&manifest_file, MANIFEST.replace("echo", "tampered")).unwrap();
        let err = verify_lock(&opts.out_dir, Some(&manifest_file))
            .expect_err("should fail after manifest tamper");
        let msg = err.to_string();
        assert!(
            msg.contains("LockfileDrift::ManifestHash"),
            "expected ManifestHash, got: {msg}"
        );
    }

    /// `verify_lock` returns `LockfileDrift::RuntimeVersion` when the lockfile has a different version.
    #[test]
    fn mcpact_verify_lock_fails_on_runtime_version_drift() {
        let manifest = Manifest::parse(MANIFEST).unwrap();
        let dir = tempdir().unwrap();
        let manifest_file = dir.path().join("mcpact.toml");
        fs::write(&manifest_file, MANIFEST).unwrap();
        let opts = GenerateOptions {
            out_dir: dir.path().join("generated"),
            dependency_source: DependencySource::WorkspacePath,
            mcpact_workspace_path: "../..".into(),
            source_manifest_path: Some(manifest_file.clone()),
            write_lockfile: true,
        };
        generate_crate(&manifest, &opts).unwrap();
        // Tamper the runtime_wrapper_version in the lockfile.
        let lock_path = opts.out_dir.join(".mcpact/mcpact.lock");
        let original = fs::read_to_string(&lock_path).unwrap();
        let tampered = original.replace(
            &format!("runtime_wrapper_version = \"{RUNTIME_WRAPPER_VERSION}\""),
            "runtime_wrapper_version = \"99.99.99\"",
        );
        assert_ne!(
            original, tampered,
            "tamper should have changed the lockfile"
        );
        fs::write(&lock_path, tampered).unwrap();
        let err = verify_lock(&opts.out_dir, Some(&manifest_file))
            .expect_err("should fail after runtime version tamper");
        let msg = err.to_string();
        assert!(
            msg.contains("LockfileDrift::RuntimeVersion"),
            "expected RuntimeVersion, got: {msg}"
        );
    }

    /// `verify_lock` returns `LockfileDrift::AuthoritySchema` when authority schema is tampered.
    #[test]
    fn mcpact_verify_lock_fails_on_authority_schema_drift() {
        let manifest = Manifest::parse(MANIFEST).unwrap();
        let dir = tempdir().unwrap();
        let manifest_file = dir.path().join("mcpact.toml");
        fs::write(&manifest_file, MANIFEST).unwrap();
        let opts = GenerateOptions {
            out_dir: dir.path().join("generated"),
            dependency_source: DependencySource::WorkspacePath,
            mcpact_workspace_path: "../..".into(),
            source_manifest_path: Some(manifest_file.clone()),
            write_lockfile: true,
        };
        generate_crate(&manifest, &opts).unwrap();
        // Tamper the authority schema on disk.
        let authority_path = opts.out_dir.join(".mcpact/schemas/authority.schema.json");
        fs::write(&authority_path, b"{}").unwrap();
        let err = verify_lock(&opts.out_dir, Some(&manifest_file))
            .expect_err("should fail after authority schema tamper");
        let msg = err.to_string();
        assert!(
            msg.contains("LockfileDrift::AuthoritySchema")
                || msg.contains("LockfileDrift::FileHash"),
            "expected AuthoritySchema or FileHash drift, got: {msg}"
        );
    }

    // ── G-1 portability gate ────────────────────────────────────────────────

    /// Extract only the executable (non-comment) lines of the
    /// generated `Cargo.toml`. Used by the portability assertion so the
    /// commented-out `[patch.crates-io]` block in `CratesIoPinned`
    /// mode is correctly classified as opt-in (not customer-facing).
    fn executable_cargo_toml_lines(text: &str) -> Vec<&str> {
        text.lines()
            .filter(|line| !line.trim_start().starts_with('#'))
            .collect()
    }

    /// G-1 portability gate (anchor: portability-audit-2026-05-20).
    ///
    /// `GenerateOptions::new` is the customer-facing entry point. Its
    /// default `dependency_source` MUST emit zero `path = ...` lines
    /// outside a commented `[patch.crates-io]` opt-in block. If a
    /// future change makes `WorkspacePath` the default again, this
    /// test fails fast — the same failure shape that breaks
    /// `cargo install <generated-mcp>` for v0.2 customers.
    #[test]
    fn cargo_toml_default_has_no_unpinned_sibling_path_deps() {
        let manifest = Manifest::parse(MANIFEST).unwrap();
        let dir = tempdir().unwrap();
        let opts = GenerateOptions::new(dir.path());
        // Constructive guard against silent default flip.
        assert_eq!(
            opts.dependency_source,
            DependencySource::CratesIoPinned,
            "GenerateOptions::new default must be the G-1-conformant CratesIoPinned mode"
        );
        generate_crate(&manifest, &opts).unwrap();
        let cargo_toml = fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
        for line in executable_cargo_toml_lines(&cargo_toml) {
            // Path-style sibling deps look like `name = { path = "..." }`
            // or `name = { version = "...", path = "..." }`. Both forms
            // break a customer install. The only sanctioned `path = ...`
            // line is inside the commented-out `[patch.crates-io]`
            // block — which the comment filter above excludes.
            assert!(
                !line.contains("path ="),
                "default-generated Cargo.toml leaked a path-dep line: {line:?}\n\
                 G-1 portability gate (portability-audit-2026-05-20) requires \
                 version-pinned crates.io deps in the default mode."
            );
        }
        // Sanity: the crates.io deps are present with the documented
        // version-requirement string.
        for name in MCPACT_DEPS {
            let expected = format!("{name} = \"{PUBLISHED_VERSION_REQ}\"");
            assert!(
                cargo_toml.contains(&expected),
                "default Cargo.toml missing version-pinned crates.io line for {name}: \
                 expected `{expected}` in:\n{cargo_toml}"
            );
        }
    }

    /// In `CratesIoPinned` mode the `[patch.crates-io]` opt-in block
    /// is emitted (commented out) so a developer can flip to local
    /// path-deps without editing the version-pinned lines above.
    #[test]
    fn crates_io_pinned_emits_commented_patch_block() {
        let manifest = Manifest::parse(MANIFEST).unwrap();
        let dir = tempdir().unwrap();
        let opts = GenerateOptions::new(dir.path());
        assert_eq!(opts.dependency_source, DependencySource::CratesIoPinned);
        generate_crate(&manifest, &opts).unwrap();
        let cargo_toml = fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
        assert!(
            cargo_toml.contains("# [patch.crates-io]"),
            "CratesIoPinned mode must emit a commented `[patch.crates-io]` opt-in block"
        );
        for name in MCPACT_DEPS {
            let expected = format!(
                "# {name} = {{ path = \"../mcpact/crates/{name}\", version = \"{PUBLISHED_VERSION_REQ}\" }}"
            );
            assert!(
                cargo_toml.contains(&expected),
                "CratesIoPinned mode missing commented patch line for {name}: \
                 expected `{expected}`"
            );
        }
    }

    /// `CratesIo` mode emits version-pinned deps and NO patch block.
    /// This is the shape downstream published consumers should use.
    #[test]
    fn crates_io_mode_is_version_pinned_only() {
        let manifest = Manifest::parse(MANIFEST).unwrap();
        let dir = tempdir().unwrap();
        let opts = GenerateOptions {
            out_dir: dir.path().to_path_buf(),
            dependency_source: DependencySource::CratesIo,
            mcpact_workspace_path: "../..".into(),
            source_manifest_path: None,
            write_lockfile: true,
        };
        generate_crate(&manifest, &opts).unwrap();
        let cargo_toml = fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
        assert!(
            !cargo_toml.contains("[patch.crates-io]"),
            "CratesIo mode must not emit a patch block (commented or not)"
        );
        for name in MCPACT_DEPS {
            let expected = format!("{name} = \"{PUBLISHED_VERSION_REQ}\"");
            assert!(
                cargo_toml.contains(&expected),
                "CratesIo mode missing version-pinned line for {name}"
            );
        }
    }

    /// `WorkspacePath` mode emits local path deps with crates.io
    /// version pins and NO patch block. This mode is still only for
    /// source-checkout builds, but it must never regress to path-only
    /// sibling deps.
    #[test]
    fn workspace_path_mode_emits_version_pinned_path_deps_no_patch_block() {
        let manifest = Manifest::parse(MANIFEST).unwrap();
        let dir = tempdir().unwrap();
        let opts = GenerateOptions {
            out_dir: dir.path().to_path_buf(),
            dependency_source: DependencySource::WorkspacePath,
            mcpact_workspace_path: "../..".into(),
            source_manifest_path: None,
            write_lockfile: true,
        };
        generate_crate(&manifest, &opts).unwrap();
        let cargo_toml = fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
        assert!(
            !cargo_toml.contains("[patch.crates-io]"),
            "WorkspacePath mode must not emit a patch block"
        );
        for name in MCPACT_DEPS {
            let line = cargo_toml
                .lines()
                .find(|line| line.starts_with(&format!("{name} = ")))
                .unwrap_or_else(|| panic!("WorkspacePath mode missing dep line for {name}"));
            assert!(
                line.contains("path = "),
                "WorkspacePath mode must emit a path-style dep for {name}: {line}"
            );
            assert!(
                line.contains(&format!("version = \"{PUBLISHED_VERSION_REQ}\"")),
                "WorkspacePath mode must version-pin local path dep for {name}: {line}"
            );
        }
    }

    #[test]
    fn workspace_path_mode_preserves_cli_computed_relative_workspace_path() {
        let manifest = Manifest::parse(MANIFEST).unwrap();
        let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
        let out_dir =
            workspace_root.join("target/mcpact-generate-tests/already-relative-workspace-path");
        let _ = fs::remove_dir_all(&out_dir);
        let opts = GenerateOptions {
            out_dir: out_dir.clone(),
            dependency_source: DependencySource::WorkspacePath,
            mcpact_workspace_path: "../../..".into(),
            source_manifest_path: None,
            write_lockfile: true,
        };
        generate_crate(&manifest, &opts).unwrap();
        let cargo_toml = fs::read_to_string(out_dir.join("Cargo.toml")).unwrap();
        assert!(
            cargo_toml.contains(&format!(
                "mcpact-core = {{ path = \"../../../crates/mcpact-core\", version = \"{PUBLISHED_VERSION_REQ}\" }}"
            )),
            "generator should preserve the CLI-computed relative workspace path; got:\n{cargo_toml}"
        );
    }

    #[test]
    fn workspace_path_mode_normalizes_absolute_workspace_path_to_relative() {
        let manifest = Manifest::parse(MANIFEST).unwrap();
        let dir = tempdir().unwrap();
        let workspace = dir.path().join("siblings/mcpact");
        let out_dir = dir.path().join("siblings/mcp-hosts/generated/mcp-echo");
        fs::create_dir_all(&workspace).unwrap();
        let opts = GenerateOptions {
            out_dir: out_dir.clone(),
            dependency_source: DependencySource::WorkspacePath,
            mcpact_workspace_path: workspace.to_string_lossy().into_owned(),
            source_manifest_path: None,
            write_lockfile: true,
        };

        generate_crate(&manifest, &opts).unwrap();

        let cargo_toml = fs::read_to_string(out_dir.join("Cargo.toml")).unwrap();
        assert!(
            !cargo_toml.contains(&workspace.to_string_lossy().to_string()),
            "generator must not bake absolute workspace paths into Cargo.toml:\n{cargo_toml}"
        );
        assert!(
            cargo_toml.contains(&format!(
                "mcpact-core = {{ path = \"../../../mcpact/crates/mcpact-core\", version = \"{PUBLISHED_VERSION_REQ}\" }}"
            )),
            "generator should emit sibling-relative mcpact deps; got:\n{cargo_toml}"
        );
    }

    /// G-2 schema-cohesion smoke (anchor: portability-audit-2026-05-20 §4).
    ///
    /// The emitted `authority.schema.json` enum must list exactly the
    /// 6 canonical `AuthorityClass` variant names — `Analyze`, never
    /// `Derive`. Sibling to the snapshot test in `mcpact-core`; this
    /// test guards the emitted wire format.
    #[test]
    fn authority_schema_emits_canonical_variants() {
        let schema_text = render_authority_schema().expect("schema must render");
        let schema: serde_json::Value =
            serde_json::from_str(&schema_text).expect("schema must be JSON");
        let names: Vec<String> = schema["definitions"]["AuthorityClass"]["enum"]
            .as_array()
            .expect("AuthorityClass.enum must be an array")
            .iter()
            .map(|value| {
                value
                    .as_str()
                    .expect("AuthorityClass.enum entries must be strings")
                    .to_string()
            })
            .collect();
        assert_eq!(
            names,
            vec![
                "Observe".to_string(),
                "Analyze".to_string(),
                "Plan".to_string(),
                "Mutate".to_string(),
                "Destroy".to_string(),
                "Credential".to_string(),
            ],
            "authority.schema.json enum drifted from canonical AuthorityClass"
        );
        for name in &names {
            assert_ne!(
                name, "Derive",
                "authority.schema.json must not introduce a `Derive` variant; \
                 the canonical spelling is `Analyze`"
            );
        }
    }
}
