n7n-plugin-host 0.1.0

wasmtime-бекенд для crate::registry::PluginRegistry (n7n-harness) — друга (wasm-компонентна) реалізація слотів detector/verify/ast_facts/t0, поруч з in-process-бекендом §3.12 пункту 16а
//! Wasm-бекенд поверхні `t0` (`wit/t0.wit`, world `t0-plugin`) — друга
//! реалізація [`harness::pipeline::T0Fn`] (лише `plan`, доккоментар
//! `crate` "T0 повертає edit-plan, хост застосовує": `PreparePlanFn`/
//! `CommitPlanFn` сюди не входять, `n7n-llm-lib` тут узятий з
//! `default-features = false`, `write_guard` цьому крейту недоступний за
//! побудовою — той самий принцип, що вже застосовує `harness::registry`).

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

use wasmtime::component::{Component, HasSelf, Linker, ResourceTable};
use wasmtime::{Engine, Store};

use harness::pipeline::T0Fn;
use llm_lib::edit_plan::{AnchoredEdit, EditPlan, FileEditPlan};

use crate::engine::new_engine;
use crate::host_state::HostState;
use crate::wasi_ctx::read_only_repo_ctx;

mod bindings {
    wasmtime::component::bindgen!({
        path: "../../wit",
        world: "t0-plugin",
    });
}

// Вкладені типи (`FileEditPlan`/`AnchoredEdit`/...) живуть під
// `bindings::n7n::fix_deps::types` (БЕЗ `exports::`), не під
// `bindings::exports::n7n::fix_deps::t0` — доккоментар `wit/t0.wit`,
// секція про `import types;`, пояснює, чому bindgen! кладе їх саме туди.
// Лише сам `EditPlan` (повернене значення `plan()`) лишається під
// `exports::…::t0::EditPlan`, бо це тип експортованої функції.
impl From<bindings::n7n::fix_deps::types::AnchoredEdit> for AnchoredEdit {
    fn from(e: bindings::n7n::fix_deps::types::AnchoredEdit) -> Self {
        AnchoredEdit {
            anchor: e.anchor,
            line: e.line as usize,
            new_text: e.new_text,
        }
    }
}

/// Дзеркало `variant file-edit-plan` → [`FileEditPlan`] — доккоментар
/// `detector.rs::From<...Violation> for Violation`: РЕАЛЬНИЙ конвертер
/// `bindgen!`-типу, не ручне дзеркало. WIT `variant` мапиться на Rust
/// `enum` з ОДНІЄЮ tuple-змінною на case (`Anchored(AnchoredFileEdit)`
/// тощо) — той самий природний вибір, що вже коментує `wit/types.wit`
/// ("WIT висловлює той самий вибір НАТИВНО через variant").
impl From<bindings::n7n::fix_deps::types::FileEditPlan> for FileEditPlan {
    fn from(plan: bindings::n7n::fix_deps::types::FileEditPlan) -> Self {
        use bindings::n7n::fix_deps::types::FileEditPlan as Wit;
        match plan {
            Wit::Anchored(a) => FileEditPlan::Anchored {
                path: PathBuf::from(a.path),
                edits: a.edits.into_iter().map(AnchoredEdit::from).collect(),
            },
            Wit::Create(c) => FileEditPlan::Create {
                path: PathBuf::from(c.path),
                content: c.content,
            },
            Wit::Delete(d) => FileEditPlan::Delete {
                path: PathBuf::from(d.path),
            },
        }
    }
}

impl From<bindings::exports::n7n::fix_deps::t0::EditPlan> for EditPlan {
    fn from(plan: bindings::exports::n7n::fix_deps::t0::EditPlan) -> Self {
        EditPlan {
            files: plan.files.into_iter().map(FileEditPlan::from).collect(),
        }
    }
}

impl bindings::n7n::fix_deps::host_fs::Host for HostState {
    fn read_file(&mut self, path: String) -> Result<String, String> {
        std::fs::read_to_string(self.repo_root.join(path)).map_err(|e| e.to_string())
    }
}

/// Wasm-бекенд одного `t0`-компонента — доккоментар
/// [`crate::detector::WasmDetector`].
pub struct WasmT0 {
    engine: Engine,
    component: Component,
    linker: Linker<HostState>,
    repo_root: PathBuf,
}

impl WasmT0 {
    /// Доккоментар [`crate::detector::WasmDetector::from_file`].
    pub fn from_file(
        repo_root: impl Into<PathBuf>,
        wasm_path: impl AsRef<Path>,
    ) -> wasmtime::Result<Self> {
        let engine = new_engine()?;
        let component = Component::from_file(&engine, wasm_path.as_ref())?;
        let mut linker = Linker::<HostState>::new(&engine);
        wasmtime_wasi::p2::add_to_linker_sync(&mut linker)?;
        bindings::n7n::fix_deps::host_fs::add_to_linker::<_, HasSelf<HostState>>(
            &mut linker,
            |s| s,
        )?;
        Ok(Self {
            engine,
            component,
            linker,
            repo_root: repo_root.into(),
        })
    }

    fn call(&self) -> wasmtime::Result<EditPlan> {
        let wasi = read_only_repo_ctx(&self.repo_root)?;
        let mut store = Store::new(
            &self.engine,
            HostState {
                wasi,
                table: ResourceTable::new(),
                repo_root: self.repo_root.clone(),
            },
        );
        let instance = bindings::T0Plugin::instantiate(&mut store, &self.component, &self.linker)?;
        let plan = instance.n7n_fix_deps_t0().call_plan(&mut store)?;
        Ok(EditPlan::from(plan))
    }

    /// Перетворює на [`T0Fn`]. `t0.wit` (`plan: func() -> edit-plan`) не
    /// має каналу помилки — той самий брак, що вже несе Rust-сигнатура
    /// (`Arc<dyn Fn() -> BoxFuture<'static, EditPlan>>`, без `Result`).
    /// Інфраструктурний збій викликового шляху мапиться в
    /// `EditPlan::empty()` — БЕЗПЕЧНИЙ сентинел "нічого не робимо", той
    /// самий, яким порожній план уже означає "T0 нічого не зробив"
    /// (доккоментар `edit_plan.rs`); guest, що не зміг порахувати план,
    /// НІКОЛИ не мапиться на "план, що щось змінює".
    #[must_use]
    pub fn into_t0_fn(self) -> T0Fn {
        let this = Arc::new(self);
        Arc::new(move || {
            let this = Arc::clone(&this);
            Box::pin(async move {
                this.call().unwrap_or_else(|err| {
                    eprintln!("wasm-компонент t0: {err}");
                    EditPlan::empty()
                })
            })
        })
    }
}

#[cfg(test)]
mod tests {
    //! Доккоментар `detector.rs::tests` — той самий компіляційний доказ
    //! без `.wasm`, тут для `edit-plan` (включно з вкладеними
    //! `file-edit-plan`-варіантами, доккоментар `wit/t0.wit` про
    //! `import types;`).
    use super::*;

    #[test]
    fn bindgen_edit_plan_converts_all_three_file_edit_plan_variants() {
        let wit_plan = bindings::exports::n7n::fix_deps::t0::EditPlan {
            files: vec![
                bindings::n7n::fix_deps::types::FileEditPlan::Anchored(
                    bindings::n7n::fix_deps::types::AnchoredFileEdit {
                        path: "src/a.rs".to_string(),
                        edits: vec![bindings::n7n::fix_deps::types::AnchoredEdit {
                            anchor: "1a2".to_string(),
                            line: 3,
                            new_text: Some("новий рядок".to_string()),
                        }],
                    },
                ),
                bindings::n7n::fix_deps::types::FileEditPlan::Create(
                    bindings::n7n::fix_deps::types::CreateFileEdit {
                        path: "src/b.rs".to_string(),
                        content: "вміст\n".to_string(),
                    },
                ),
                bindings::n7n::fix_deps::types::FileEditPlan::Delete(
                    bindings::n7n::fix_deps::types::DeleteFileEdit {
                        path: "src/c.rs".to_string(),
                    },
                ),
            ],
        };

        let plan = EditPlan::from(wit_plan);

        assert_eq!(
            plan,
            EditPlan {
                files: vec![
                    FileEditPlan::Anchored {
                        path: PathBuf::from("src/a.rs"),
                        edits: vec![AnchoredEdit {
                            anchor: "1a2".to_string(),
                            line: 3,
                            new_text: Some("новий рядок".to_string()),
                        }],
                    },
                    FileEditPlan::Create {
                        path: PathBuf::from("src/b.rs"),
                        content: "вміст\n".to_string(),
                    },
                    FileEditPlan::Delete {
                        path: PathBuf::from("src/c.rs"),
                    },
                ],
            }
        );
    }

    #[test]
    fn bindgen_anchored_edit_deletion_sentinel_survives_conversion() {
        let wit_edit = bindings::n7n::fix_deps::types::AnchoredEdit {
            anchor: "xyz".to_string(),
            line: 7,
            new_text: None,
        };

        assert_eq!(AnchoredEdit::from(wit_edit).new_text, None);
    }
}