Skip to main content

algocline_app/service/
engine_api_impl.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use algocline_core::{EngineApi, QueryResponse};
5use algocline_engine::state::{ResetReport, StateError};
6use async_trait::async_trait;
7
8use super::list_opts::ListOpts;
9use super::AppService;
10
11/// Delegates each [`EngineApi`] method to the corresponding `AppService`
12/// inherent method via fully-qualified syntax (`AppService::method(self, …)`).
13///
14/// This avoids ambiguity between the trait method and the inherent method
15/// of the same name, preventing accidental infinite recursion if the
16/// inherent method is ever removed or renamed.
17#[async_trait]
18impl EngineApi for AppService {
19    // ─── Core execution ──────────────────────────────────────
20
21    async fn run(
22        &self,
23        code: Option<String>,
24        code_file: Option<String>,
25        ctx: Option<serde_json::Value>,
26        project_root: Option<String>,
27        host_mode: Option<bool>,
28    ) -> Result<String, String> {
29        AppService::run(self, code, code_file, ctx, project_root, host_mode).await
30    }
31
32    async fn advice(
33        &self,
34        strategy: &str,
35        task: Option<String>,
36        opts: Option<serde_json::Value>,
37        project_root: Option<String>,
38    ) -> Result<String, String> {
39        AppService::advice(self, strategy, task, opts, project_root).await
40    }
41
42    async fn continue_single(
43        &self,
44        session_id: &str,
45        response: String,
46        query_id: Option<&str>,
47        usage: Option<algocline_core::TokenUsage>,
48    ) -> Result<String, String> {
49        AppService::continue_single(self, session_id, response, query_id, usage).await
50    }
51
52    async fn continue_batch(
53        &self,
54        session_id: &str,
55        responses: Vec<QueryResponse>,
56    ) -> Result<String, String> {
57        AppService::continue_batch(self, session_id, responses).await
58    }
59
60    // ─── Session status ──────────────────────────────────────
61
62    async fn status(
63        &self,
64        session_id: Option<&str>,
65        pending_filter: Option<serde_json::Value>,
66        include_history: bool,
67    ) -> Result<String, String> {
68        AppService::status(self, session_id, pending_filter, include_history).await
69    }
70
71    // ─── Evaluation ──────────────────────────────────────────
72
73    async fn eval(
74        &self,
75        scenario: Option<String>,
76        scenario_file: Option<String>,
77        scenario_name: Option<String>,
78        strategy: &str,
79        strategy_opts: Option<serde_json::Value>,
80        auto_card: bool,
81    ) -> Result<String, String> {
82        AppService::eval(
83            self,
84            scenario,
85            scenario_file,
86            scenario_name,
87            strategy,
88            strategy_opts,
89            auto_card,
90        )
91        .await
92    }
93
94    async fn eval_history(&self, strategy: Option<&str>, limit: usize) -> Result<String, String> {
95        AppService::eval_history(self, strategy, limit)
96    }
97
98    async fn eval_detail(&self, eval_id: &str) -> Result<String, String> {
99        AppService::eval_detail(self, eval_id)
100    }
101
102    async fn eval_compare(&self, eval_id_a: &str, eval_id_b: &str) -> Result<String, String> {
103        AppService::eval_compare(self, eval_id_a, eval_id_b).await
104    }
105
106    // ─── Scenarios ───────────────────────────────────────────
107
108    async fn scenario_list(&self) -> Result<String, String> {
109        AppService::scenario_list(self)
110    }
111
112    async fn scenario_show(&self, name: &str) -> Result<String, String> {
113        AppService::scenario_show(self, name)
114    }
115
116    async fn scenario_install(&self, url: String) -> Result<String, String> {
117        AppService::scenario_install(self, url).await
118    }
119
120    // ─── Packages ────────────────────────────────────────────
121
122    async fn pkg_link(
123        &self,
124        path: String,
125        name: Option<String>,
126        force: Option<bool>,
127        scope: Option<String>,
128        project_root: Option<String>,
129    ) -> Result<String, String> {
130        AppService::pkg_link(self, path, name, force, scope, project_root).await
131    }
132
133    async fn pkg_unlink(&self, name: String) -> Result<String, String> {
134        AppService::pkg_unlink(self, name).await
135    }
136
137    #[allow(clippy::too_many_arguments)]
138    async fn pkg_list(
139        &self,
140        project_root: Option<String>,
141        limit: Option<i32>,
142        sort: Option<String>,
143        filter: Option<serde_json::Value>,
144        fields: Option<Vec<String>>,
145        verbose: Option<String>,
146    ) -> Result<String, String> {
147        // `filter` is a free-form JSON Value at the MCP boundary (so the
148        // trait stays core-crate-pure). If the caller sends something
149        // that is not a JSON object we treat it as "no filter" and log
150        // the drop so operators can diagnose unexpected filter shapes
151        // in production.
152        let filter_map = match filter {
153            None => None,
154            Some(v) => match serde_json::from_value::<HashMap<String, serde_json::Value>>(v) {
155                Ok(map) => Some(map),
156                Err(e) => {
157                    tracing::warn!(error = %e, "pkg_list: filter value is not a JSON object — treating as no filter");
158                    None
159                }
160            },
161        };
162
163        // Negative limit values from MCP callers are clamped to 0 rather
164        // than wrapping to a huge usize (unchecked-user-bound-input pattern).
165        // Downstream semantics: `Some(0)` means "no limit" (return all) —
166        // the truncate path in `AppService::pkg_list` short-circuits on 0.
167        let opts = ListOpts {
168            limit: limit.map(|n| n.max(0) as usize),
169            sort,
170            filter: filter_map,
171            fields,
172            verbose,
173        };
174
175        AppService::pkg_list(self, project_root, opts)
176            .await
177            .map_err(|e| e.to_string())
178    }
179
180    async fn pkg_install(
181        &self,
182        url: String,
183        name: Option<String>,
184        force: Option<bool>,
185    ) -> Result<String, String> {
186        AppService::pkg_install(self, url, name, force).await
187    }
188
189    async fn pkg_remove(
190        &self,
191        name: &str,
192        project_root: Option<String>,
193        version: Option<String>,
194        scope: Option<String>,
195    ) -> Result<String, String> {
196        AppService::pkg_remove(self, name, project_root, version, scope).await
197    }
198
199    async fn pkg_repair(
200        &self,
201        name: Option<String>,
202        project_root: Option<String>,
203    ) -> Result<String, String> {
204        AppService::pkg_repair(self, name, project_root).await
205    }
206
207    async fn pkg_doctor(
208        &self,
209        name: Option<String>,
210        project_root: Option<String>,
211    ) -> Result<String, String> {
212        AppService::pkg_doctor(self, name, project_root).await
213    }
214
215    /// Run mlua-lspec tests for a package, a single file, or inline code.
216    ///
217    /// Forwards to [`AppService::pkg_test`]. See trait doc for full contract.
218    #[allow(clippy::too_many_arguments)]
219    async fn pkg_test(
220        &self,
221        pkg: Option<String>,
222        code_file: Option<String>,
223        code: Option<String>,
224        spec_dir: Option<String>,
225        filter: Option<String>,
226        search_paths: Option<Vec<String>>,
227        project_root: Option<String>,
228        auto_search_paths: Option<bool>,
229    ) -> Result<String, String> {
230        AppService::pkg_test(
231            self,
232            pkg,
233            code_file,
234            code,
235            spec_dir,
236            filter,
237            search_paths,
238            project_root,
239            auto_search_paths,
240        )
241        .await
242    }
243
244    // ─── Logging ─────────────────────────────────────────────
245
246    async fn add_note(
247        &self,
248        session_id: &str,
249        content: &str,
250        title: Option<&str>,
251    ) -> Result<String, String> {
252        AppService::add_note(self, session_id, content, title).await
253    }
254
255    async fn log_view(
256        &self,
257        session_id: Option<&str>,
258        limit: Option<usize>,
259        max_chars: Option<usize>,
260    ) -> Result<String, String> {
261        AppService::log_view(self, session_id, limit, max_chars).await
262    }
263
264    async fn stats(
265        &self,
266        strategy_filter: Option<&str>,
267        days: Option<u64>,
268    ) -> Result<String, String> {
269        AppService::stats(self, strategy_filter, days)
270    }
271
272    // ─── Project lifecycle ────────────────────────────────────
273
274    async fn init(&self, project_root: Option<String>) -> Result<String, String> {
275        AppService::init(self, project_root).await
276    }
277
278    async fn update(&self, project_root: Option<String>) -> Result<String, String> {
279        AppService::update(self, project_root).await
280    }
281
282    async fn migrate(&self, project_root: Option<String>) -> Result<String, String> {
283        AppService::migrate(self, project_root).await
284    }
285
286    // ─── Session activation (issue #1776627475) ──────────────
287
288    async fn session_new(
289        &self,
290        project_root: Option<String>,
291        mode: Option<String>,
292    ) -> Result<String, String> {
293        let session = self.activate_session(project_root.as_deref(), mode.as_deref())?;
294        let result = serde_json::json!({
295            "session_id": session.session_id,
296            "project_root": session
297                .project_root
298                .as_ref()
299                .map(|p| p.to_string_lossy().to_string()),
300            "mode": session.mode.as_str(),
301        });
302        serde_json::to_string_pretty(&result).map_err(|e| e.to_string())
303    }
304
305    // ─── Cards ───────────────────────────────────────────────
306
307    async fn card_list(&self, pkg: Option<String>) -> Result<String, String> {
308        AppService::card_list(self, pkg.as_deref())
309    }
310
311    async fn card_get(&self, card_id: &str) -> Result<String, String> {
312        AppService::card_get(self, card_id)
313    }
314
315    async fn card_find(
316        &self,
317        pkg: Option<String>,
318        where_: Option<serde_json::Value>,
319        order_by: Option<serde_json::Value>,
320        limit: Option<usize>,
321        offset: Option<usize>,
322    ) -> Result<String, String> {
323        AppService::card_find(self, pkg, where_, order_by, limit, offset)
324    }
325
326    async fn card_alias_list(&self, pkg: Option<String>) -> Result<String, String> {
327        AppService::card_alias_list(self, pkg.as_deref())
328    }
329
330    async fn card_get_by_alias(&self, name: &str) -> Result<String, String> {
331        AppService::card_get_by_alias(self, name)
332    }
333
334    async fn card_alias_set(
335        &self,
336        name: &str,
337        card_id: &str,
338        pkg: Option<String>,
339        note: Option<String>,
340    ) -> Result<String, String> {
341        AppService::card_alias_set(self, name, card_id, pkg.as_deref(), note.as_deref())
342    }
343
344    async fn card_append(
345        &self,
346        card_id: &str,
347        fields: serde_json::Value,
348    ) -> Result<String, String> {
349        AppService::card_append(self, card_id, fields)
350    }
351
352    async fn card_install(&self, url: String) -> Result<String, String> {
353        AppService::card_install(self, url).await
354    }
355
356    async fn card_samples(
357        &self,
358        card_id: &str,
359        offset: Option<usize>,
360        limit: Option<usize>,
361        where_: Option<serde_json::Value>,
362    ) -> Result<String, String> {
363        AppService::card_samples(self, card_id, offset.unwrap_or(0), limit, where_)
364    }
365
366    async fn card_lineage(
367        &self,
368        card_id: &str,
369        direction: Option<String>,
370        depth: Option<usize>,
371        include_stats: Option<bool>,
372        relation_filter: Option<Vec<String>>,
373    ) -> Result<String, String> {
374        AppService::card_lineage(
375            self,
376            card_id,
377            direction.as_deref(),
378            depth,
379            include_stats,
380            relation_filter,
381        )
382    }
383
384    async fn card_sink_backfill(&self, sink: String, dry_run: bool) -> Result<String, String> {
385        AppService::card_sink_backfill(self, super::card::SinkBackfillParams { sink, dry_run })
386    }
387
388    async fn card_analyze(&self, card_id: &str, pkg: Option<String>) -> Result<String, String> {
389        AppService::card_analyze(self, card_id, pkg).await
390    }
391
392    async fn card_publish(
393        &self,
394        card_id: &str,
395        target_repo: &str,
396        commit_message: Option<&str>,
397    ) -> Result<String, String> {
398        AppService::card_publish(self, card_id, target_repo, commit_message).await
399    }
400
401    // ─── Hub ─────────────────────────────────────────────────
402
403    async fn hub_reindex(
404        &self,
405        output_path: Option<String>,
406        source_dir: Option<String>,
407    ) -> Result<String, String> {
408        self.hub_reindex(output_path.as_deref(), source_dir.as_deref())
409            .await
410    }
411
412    async fn hub_gendoc(
413        &self,
414        source_dir: String,
415        out_dir: Option<String>,
416        projections: Option<Vec<String>>,
417        config_path: Option<String>,
418        lint_strict: Option<bool>,
419    ) -> Result<String, String> {
420        let svc = self.clone();
421        tokio::task::spawn_blocking(move || {
422            crate::AppService::hub_gendoc(
423                &svc,
424                &source_dir,
425                out_dir.as_deref(),
426                projections.as_deref(),
427                config_path.as_deref(),
428                lint_strict,
429            )
430        })
431        .await
432        .map_err(|e| format!("hub_gendoc task panicked: {e}"))?
433    }
434
435    async fn hub_dist(
436        &self,
437        source_dir: String,
438        output_path: Option<String>,
439        out_dir: Option<String>,
440        preset: Option<String>,
441        project_root: Option<String>,
442        projections: Option<Vec<String>>,
443        config_path: Option<String>,
444        lint_strict: Option<bool>,
445    ) -> Result<String, String> {
446        self.hub_dist(
447            &source_dir,
448            output_path.as_deref(),
449            out_dir.as_deref(),
450            preset.as_deref(),
451            project_root.as_deref(),
452            projections.as_deref(),
453            config_path.as_deref(),
454            lint_strict,
455        )
456        .await
457    }
458
459    async fn hub_info(&self, pkg: String) -> Result<String, String> {
460        let svc = self.clone();
461        tokio::task::spawn_blocking(move || AppService::hub_info(&svc, &pkg))
462            .await
463            .map_err(|e| format!("hub_info task panicked: {e}"))?
464    }
465
466    #[allow(clippy::too_many_arguments)]
467    async fn hub_search(
468        &self,
469        query: Option<String>,
470        category: Option<String>,
471        installed_only: Option<bool>,
472        limit: Option<i32>,
473        sort: Option<String>,
474        filter: Option<serde_json::Value>,
475        fields: Option<Vec<String>>,
476        verbose: Option<String>,
477        local_indices: Option<Vec<String>>,
478    ) -> Result<String, String> {
479        let svc = self.clone();
480
481        // `filter` is a free-form JSON Value at the MCP boundary (so the
482        // trait stays core-crate-pure). If the caller sends something
483        // that is not a JSON object we treat it as "no filter" — the
484        // explicit category/installed_only params still cover the common
485        // cases. The MCP `JsonSchema` layer will have already flagged
486        // hard type errors. We log the drop so operators can diagnose
487        // unexpected filter shapes in production.
488        let filter_map = match filter {
489            None => None,
490            Some(v) => match serde_json::from_value::<HashMap<String, serde_json::Value>>(v) {
491                Ok(map) => Some(map),
492                Err(e) => {
493                    tracing::warn!(error = %e, "hub_search: filter value is not a JSON object — treating as no filter");
494                    None
495                }
496            },
497        };
498
499        // Negative limit values from MCP callers are clamped to 0 rather
500        // than wrapping to a huge usize (unchecked-user-bound-input pattern).
501        // Downstream semantics: `Some(0)` means "no limit" (return all) —
502        // the truncate path in `AppService::hub_search` short-circuits on 0.
503        let opts = ListOpts {
504            limit: limit.map(|n| n.max(0) as usize),
505            sort,
506            filter: filter_map,
507            fields,
508            verbose,
509        };
510
511        tokio::task::spawn_blocking(move || {
512            AppService::hub_search(
513                &svc,
514                query.as_deref(),
515                category.as_deref(),
516                installed_only,
517                opts,
518                local_indices,
519            )
520        })
521        .await
522        .map_err(|e| format!("hub_search task panicked: {e}"))?
523    }
524
525    // ─── Package read ─────────────────────────────────────────
526
527    async fn pkg_read_init_lua(&self, name: &str) -> Result<String, String> {
528        AppService::pkg_read_init_lua(self, name, None)
529    }
530
531    async fn pkg_get_narrative_md(&self, name: &str) -> Result<Option<String>, String> {
532        AppService::pkg_get_narrative_md(self, name).await
533    }
534
535    async fn pkg_meta(&self, name: &str) -> Result<String, String> {
536        let filter = serde_json::json!({ "name": name });
537        let json_str = EngineApi::pkg_list(
538            self,
539            None,
540            None,
541            None,
542            Some(filter),
543            None,
544            Some("full".to_string()),
545        )
546        .await?;
547        let val: serde_json::Value = serde_json::from_str(&json_str)
548            .map_err(|e| format!("pkg_meta: failed to parse pkg_list response: {e}"))?;
549        let pkgs = val
550            .get("packages")
551            .and_then(|p| p.as_array())
552            .ok_or_else(|| "pkg_meta: pkg_list response missing 'packages' field".to_string())?;
553        if pkgs.is_empty() {
554            return Err(format!("pkg not found: {name}"));
555        }
556        serde_json::to_string(&pkgs[0]).map_err(|e| format!("pkg_meta: serialize entry: {e}"))
557    }
558
559    // ─── Package scaffold ─────────────────────────────────────
560
561    async fn pkg_scaffold(
562        &self,
563        name: String,
564        target_dir: Option<String>,
565        category: Option<String>,
566        description: Option<String>,
567    ) -> Result<String, String> {
568        let svc = self.clone();
569        tokio::task::spawn_blocking(move || {
570            AppService::pkg_scaffold(
571                &svc,
572                &name,
573                target_dir.as_deref(),
574                category.as_deref(),
575                description.as_deref(),
576            )
577        })
578        .await
579        .map_err(|e| format!("pkg_scaffold task panicked: {e}"))?
580    }
581
582    // ─── Hub resources ───────────────────────────────────────
583
584    /// Aggregate hub index across all registered cache sources.
585    ///
586    /// Delegates to `AppService::aggregate_index`, then serializes the
587    /// result to a JSON string. Individual source failures and registry-load
588    /// failures are embedded in the response JSON under a `"warnings"` field
589    /// so the MCP caller can observe partial failures without losing the
590    /// aggregate result.
591    async fn hub_index_aggregate(&self) -> Result<String, String> {
592        let svc = self.clone();
593        let (index, warnings) = tokio::task::spawn_blocking(move || {
594            AppService::aggregate_index(&svc).map_err(|e| e.to_string())
595        })
596        .await
597        .map_err(|e| format!("hub_index_aggregate task panicked: {e}"))??;
598
599        let mut json = serde_json::to_value(&index)
600            .map_err(|e| format!("hub_index_aggregate: serialize index: {e}"))?;
601        if !warnings.is_empty() {
602            if let Some(obj) = json.as_object_mut() {
603                obj.insert("warnings".to_string(), serde_json::json!(warnings));
604            }
605        }
606        serde_json::to_string(&json)
607            .map_err(|e| format!("hub_index_aggregate: serialize final: {e}"))
608    }
609
610    // ─── Settings ────────────────────────────────────────────
611
612    async fn setting_resolve(&self, target: Option<String>) -> Result<String, String> {
613        let app_dir = self.log_config.app_dir();
614        let project_root = self.resolve_root(None);
615        tokio::task::spawn_blocking(move || {
616            crate::service::setting::resolve_setting(
617                &app_dir,
618                project_root.as_deref(),
619                target.as_deref(),
620            )
621            .map_err(|e| e.to_string())
622            .and_then(|r| {
623                serde_json::to_string(&r).map_err(|e| format!("setting_resolve: serialize: {e}"))
624            })
625        })
626        .await
627        .map_err(|e| format!("setting_resolve: task panicked: {e}"))?
628    }
629
630    // ─── State management ────────────────────────────────────
631
632    async fn state_list(&self, namespace: String) -> Result<String, String> {
633        let store = Arc::clone(&self.state_store);
634        tokio::task::spawn_blocking(move || {
635            store
636                .list_dispatched(&namespace)
637                .map_err(AppService::state_err_to_wire)
638                .and_then(|keys| {
639                    // Wire shape: { "keys": [string] } per docs/state-management.md L92.
640                    // TODO(ST2): verify this shape via real rmcp stdio in tests/e2e.rs
641                    // (test_alc_state_list_* happy path should assert parsed["keys"].is_array()).
642                    serde_json::to_string(&serde_json::json!({"keys": keys}))
643                        .map_err(|e| format!("state_list: serialize: {e}"))
644                })
645        })
646        .await
647        .map_err(|e| format!("state_list: task panicked: {e}"))?
648    }
649
650    async fn state_show(&self, namespace: String, key: String) -> Result<String, String> {
651        let store = Arc::clone(&self.state_store);
652        tokio::task::spawn_blocking(move || {
653            store
654                .show_dispatched(&namespace, &key)
655                .map_err(AppService::state_err_to_wire)
656                .and_then(|value| {
657                    serde_json::to_string(&value).map_err(|e| format!("state_show: serialize: {e}"))
658                })
659        })
660        .await
661        .map_err(|e| format!("state_show: task panicked: {e}"))?
662    }
663
664    async fn state_reset(
665        &self,
666        namespace: String,
667        key: String,
668        steps: Option<Vec<String>>,
669        fields: Option<Vec<String>>,
670    ) -> Result<String, String> {
671        let store = Arc::clone(&self.state_store);
672        // Clone input slices before moving into closure so we can echo them in the response.
673        let steps_input: Vec<String> = steps.clone().unwrap_or_default();
674        let fields_input: Vec<String> = fields.clone().unwrap_or_default();
675        tokio::task::spawn_blocking(move || {
676            let steps_slice: Vec<String> = steps.unwrap_or_default();
677            let fields_slice: Vec<String> = fields.unwrap_or_default();
678            store
679                .reset_dispatched_with_backup(&namespace, &key, &steps_slice, &fields_slice)
680                .map_err(AppService::state_err_to_wire)
681                .and_then(|report: ResetReport| {
682                    let v = serde_json::json!({
683                        "ok": true,
684                        "backup_path": report.backup_path.to_string_lossy(),
685                        "steps_removed": report.steps_removed,
686                        "steps_input": steps_input,
687                        "fields_removed": report.fields_removed,
688                        "fields_input": fields_input,
689                    });
690                    serde_json::to_string(&v).map_err(|e| format!("state_reset: serialize: {e}"))
691                })
692        })
693        .await
694        .map_err(|e| format!("state_reset: task panicked: {e}"))?
695    }
696
697    async fn state_set(
698        &self,
699        namespace: String,
700        key: String,
701        value: serde_json::Value,
702    ) -> Result<String, String> {
703        let store = Arc::clone(&self.state_store);
704        tokio::task::spawn_blocking(move || {
705            store
706                .set_dispatched(&namespace, &key, &value)
707                .map_err(AppService::state_err_to_wire)
708                .map(|_| r#"{"ok":true}"#.to_string())
709        })
710        .await
711        .map_err(|e| format!("state_set: task panicked: {e}"))?
712    }
713
714    async fn state_delete(&self, namespace: String, key: String) -> Result<String, String> {
715        let store = Arc::clone(&self.state_store);
716        tokio::task::spawn_blocking(move || {
717            store
718                .delete_dispatched(&namespace, &key)
719                .map_err(AppService::state_err_to_wire)
720                .and_then(|existed| {
721                    serde_json::to_string(&serde_json::json!({"ok": true, "existed": existed}))
722                        .map_err(|e| format!("state_delete: serialize: {e}"))
723                })
724        })
725        .await
726        .map_err(|e| format!("state_delete: task panicked: {e}"))?
727    }
728
729    // ─── Diagnostics ─────────────────────────────────────────
730
731    async fn info(&self) -> String {
732        let svc = self.clone();
733        tokio::task::spawn_blocking(move || AppService::info(&svc))
734            .await
735            .unwrap_or_else(|e| format!("{{\"error\": \"info: task panicked: {e}\"}}"))
736    }
737
738    // ─── Pool management ─────────────────────────────────────
739
740    async fn pool_ensure(&self) -> Result<String, String> {
741        AppService::pool_ensure_impl(self).await
742    }
743
744    async fn pool_status(&self, sid: Option<String>) -> Result<String, String> {
745        AppService::pool_status_impl(self, sid).await
746    }
747
748    async fn pool_stop(&self, sid: Option<String>) -> Result<String, String> {
749        AppService::pool_stop_impl(self, sid).await
750    }
751}
752
753// ─── State error → wire JSON mapper ───────────────────────────────────────
754
755impl AppService {
756    /// Convert a [`StateError`] into a typed wire error JSON string.
757    ///
758    /// Each variant maps to a distinct `"error"` code so callers can distinguish
759    /// `NOT_FOUND` from generic I/O errors at the wire level.
760    ///
761    /// # Arguments
762    /// - `e` — the engine-layer error to convert.
763    ///
764    /// # Returns
765    /// A JSON string `{"error":"<CODE>",...}`. Falls back to an INTERNAL error JSON
766    /// string if serialization itself fails (should never occur for string-only values).
767    fn state_err_to_wire(e: StateError) -> String {
768        let v = match e {
769            StateError::KeyNotFound { namespace, key } => {
770                serde_json::json!({"error": "NOT_FOUND", "namespace": namespace, "key": key})
771            }
772            StateError::UnsafeSegment { which, value } => {
773                serde_json::json!({"error": "UNSAFE_SEGMENT", "which": which, "value": value})
774            }
775            StateError::IoBackup(io_err) => {
776                serde_json::json!({"error": "IO_BACKUP", "message": io_err.to_string()})
777            }
778            StateError::IoRead(io_err) => {
779                serde_json::json!({"error": "IO_READ", "message": io_err.to_string()})
780            }
781            StateError::IoWrite(io_err) => {
782                serde_json::json!({"error": "IO_WRITE", "message": io_err.to_string()})
783            }
784            StateError::Serde(serde_err) => {
785                serde_json::json!({"error": "SERDE", "message": serde_err.to_string()})
786            }
787            StateError::ShapeInvalid { reason } => {
788                serde_json::json!({"error": "SHAPE_INVALID", "reason": reason})
789            }
790        };
791        serde_json::to_string(&v).unwrap_or_else(|e| {
792            // justification: the json! macro above only contains string values, so
793            // to_string() cannot fail under normal conditions. The unwrap_or_else
794            // is a purely defensive fallback.
795            format!("{{\"error\":\"INTERNAL\",\"message\":\"serialize failed: {e}\"}}")
796        })
797    }
798}