Skip to main content

algocline_app/service/
engine_api_impl.rs

1use std::collections::HashMap;
2
3use algocline_core::{EngineApi, QueryResponse};
4use async_trait::async_trait;
5
6use super::list_opts::ListOpts;
7use super::AppService;
8
9/// Delegates each [`EngineApi`] method to the corresponding `AppService`
10/// inherent method via fully-qualified syntax (`AppService::method(self, …)`).
11///
12/// This avoids ambiguity between the trait method and the inherent method
13/// of the same name, preventing accidental infinite recursion if the
14/// inherent method is ever removed or renamed.
15#[async_trait]
16impl EngineApi for AppService {
17    // ─── Core execution ──────────────────────────────────────
18
19    async fn run(
20        &self,
21        code: Option<String>,
22        code_file: Option<String>,
23        ctx: Option<serde_json::Value>,
24        project_root: Option<String>,
25    ) -> Result<String, String> {
26        AppService::run(self, code, code_file, ctx, project_root).await
27    }
28
29    async fn advice(
30        &self,
31        strategy: &str,
32        task: Option<String>,
33        opts: Option<serde_json::Value>,
34        project_root: Option<String>,
35    ) -> Result<String, String> {
36        AppService::advice(self, strategy, task, opts, project_root).await
37    }
38
39    async fn continue_single(
40        &self,
41        session_id: &str,
42        response: String,
43        query_id: Option<&str>,
44        usage: Option<algocline_core::TokenUsage>,
45    ) -> Result<String, String> {
46        AppService::continue_single(self, session_id, response, query_id, usage).await
47    }
48
49    async fn continue_batch(
50        &self,
51        session_id: &str,
52        responses: Vec<QueryResponse>,
53    ) -> Result<String, String> {
54        AppService::continue_batch(self, session_id, responses).await
55    }
56
57    // ─── Session status ──────────────────────────────────────
58
59    async fn status(
60        &self,
61        session_id: Option<&str>,
62        pending_filter: Option<serde_json::Value>,
63    ) -> Result<String, String> {
64        AppService::status(self, session_id, pending_filter).await
65    }
66
67    // ─── Evaluation ──────────────────────────────────────────
68
69    async fn eval(
70        &self,
71        scenario: Option<String>,
72        scenario_file: Option<String>,
73        scenario_name: Option<String>,
74        strategy: &str,
75        strategy_opts: Option<serde_json::Value>,
76        auto_card: bool,
77    ) -> Result<String, String> {
78        AppService::eval(
79            self,
80            scenario,
81            scenario_file,
82            scenario_name,
83            strategy,
84            strategy_opts,
85            auto_card,
86        )
87        .await
88    }
89
90    async fn eval_history(&self, strategy: Option<&str>, limit: usize) -> Result<String, String> {
91        AppService::eval_history(self, strategy, limit)
92    }
93
94    async fn eval_detail(&self, eval_id: &str) -> Result<String, String> {
95        AppService::eval_detail(self, eval_id)
96    }
97
98    async fn eval_compare(&self, eval_id_a: &str, eval_id_b: &str) -> Result<String, String> {
99        AppService::eval_compare(self, eval_id_a, eval_id_b).await
100    }
101
102    // ─── Scenarios ───────────────────────────────────────────
103
104    async fn scenario_list(&self) -> Result<String, String> {
105        AppService::scenario_list(self)
106    }
107
108    async fn scenario_show(&self, name: &str) -> Result<String, String> {
109        AppService::scenario_show(self, name)
110    }
111
112    async fn scenario_install(&self, url: String) -> Result<String, String> {
113        AppService::scenario_install(self, url).await
114    }
115
116    // ─── Packages ────────────────────────────────────────────
117
118    async fn pkg_link(
119        &self,
120        path: String,
121        name: Option<String>,
122        force: Option<bool>,
123        scope: Option<String>,
124        project_root: Option<String>,
125    ) -> Result<String, String> {
126        AppService::pkg_link(self, path, name, force, scope, project_root).await
127    }
128
129    async fn pkg_unlink(&self, name: String) -> Result<String, String> {
130        AppService::pkg_unlink(self, name).await
131    }
132
133    #[allow(clippy::too_many_arguments)]
134    async fn pkg_list(
135        &self,
136        project_root: Option<String>,
137        limit: Option<i32>,
138        sort: Option<String>,
139        filter: Option<serde_json::Value>,
140        fields: Option<Vec<String>>,
141        verbose: Option<String>,
142    ) -> Result<String, String> {
143        // `filter` is a free-form JSON Value at the MCP boundary (so the
144        // trait stays core-crate-pure). If the caller sends something
145        // that is not a JSON object we treat it as "no filter" and log
146        // the drop so operators can diagnose unexpected filter shapes
147        // in production.
148        let filter_map = match filter {
149            None => None,
150            Some(v) => match serde_json::from_value::<HashMap<String, serde_json::Value>>(v) {
151                Ok(map) => Some(map),
152                Err(e) => {
153                    tracing::warn!(error = %e, "pkg_list: filter value is not a JSON object — treating as no filter");
154                    None
155                }
156            },
157        };
158
159        // Negative limit values from MCP callers are clamped to 0 rather
160        // than wrapping to a huge usize (unchecked-user-bound-input pattern).
161        // Downstream semantics: `Some(0)` means "no limit" (return all) —
162        // the truncate path in `AppService::pkg_list` short-circuits on 0.
163        let opts = ListOpts {
164            limit: limit.map(|n| n.max(0) as usize),
165            sort,
166            filter: filter_map,
167            fields,
168            verbose,
169        };
170
171        AppService::pkg_list(self, project_root, opts)
172            .await
173            .map_err(|e| e.to_string())
174    }
175
176    async fn pkg_install(&self, url: String, name: Option<String>) -> Result<String, String> {
177        AppService::pkg_install(self, url, name).await
178    }
179
180    async fn pkg_remove(
181        &self,
182        name: &str,
183        project_root: Option<String>,
184        version: Option<String>,
185        scope: Option<String>,
186    ) -> Result<String, String> {
187        AppService::pkg_remove(self, name, project_root, version, scope).await
188    }
189
190    async fn pkg_repair(
191        &self,
192        name: Option<String>,
193        project_root: Option<String>,
194    ) -> Result<String, String> {
195        AppService::pkg_repair(self, name, project_root).await
196    }
197
198    async fn pkg_doctor(
199        &self,
200        name: Option<String>,
201        project_root: Option<String>,
202    ) -> Result<String, String> {
203        AppService::pkg_doctor(self, name, project_root).await
204    }
205
206    // ─── Logging ─────────────────────────────────────────────
207
208    async fn add_note(
209        &self,
210        session_id: &str,
211        content: &str,
212        title: Option<&str>,
213    ) -> Result<String, String> {
214        AppService::add_note(self, session_id, content, title).await
215    }
216
217    async fn log_view(
218        &self,
219        session_id: Option<&str>,
220        limit: Option<usize>,
221        max_chars: Option<usize>,
222    ) -> Result<String, String> {
223        AppService::log_view(self, session_id, limit, max_chars).await
224    }
225
226    async fn stats(
227        &self,
228        strategy_filter: Option<&str>,
229        days: Option<u64>,
230    ) -> Result<String, String> {
231        AppService::stats(self, strategy_filter, days)
232    }
233
234    // ─── Project lifecycle ────────────────────────────────────
235
236    async fn init(&self, project_root: Option<String>) -> Result<String, String> {
237        AppService::init(self, project_root).await
238    }
239
240    async fn update(&self, project_root: Option<String>) -> Result<String, String> {
241        AppService::update(self, project_root).await
242    }
243
244    async fn migrate(&self, project_root: Option<String>) -> Result<String, String> {
245        AppService::migrate(self, project_root).await
246    }
247
248    // ─── Cards ───────────────────────────────────────────────
249
250    async fn card_list(&self, pkg: Option<String>) -> Result<String, String> {
251        AppService::card_list(self, pkg.as_deref())
252    }
253
254    async fn card_get(&self, card_id: &str) -> Result<String, String> {
255        AppService::card_get(self, card_id)
256    }
257
258    async fn card_find(
259        &self,
260        pkg: Option<String>,
261        where_: Option<serde_json::Value>,
262        order_by: Option<serde_json::Value>,
263        limit: Option<usize>,
264        offset: Option<usize>,
265    ) -> Result<String, String> {
266        AppService::card_find(self, pkg, where_, order_by, limit, offset)
267    }
268
269    async fn card_alias_list(&self, pkg: Option<String>) -> Result<String, String> {
270        AppService::card_alias_list(self, pkg.as_deref())
271    }
272
273    async fn card_get_by_alias(&self, name: &str) -> Result<String, String> {
274        AppService::card_get_by_alias(self, name)
275    }
276
277    async fn card_alias_set(
278        &self,
279        name: &str,
280        card_id: &str,
281        pkg: Option<String>,
282        note: Option<String>,
283    ) -> Result<String, String> {
284        AppService::card_alias_set(self, name, card_id, pkg.as_deref(), note.as_deref())
285    }
286
287    async fn card_append(
288        &self,
289        card_id: &str,
290        fields: serde_json::Value,
291    ) -> Result<String, String> {
292        AppService::card_append(self, card_id, fields)
293    }
294
295    async fn card_install(&self, url: String) -> Result<String, String> {
296        AppService::card_install(self, url).await
297    }
298
299    async fn card_samples(
300        &self,
301        card_id: &str,
302        offset: Option<usize>,
303        limit: Option<usize>,
304        where_: Option<serde_json::Value>,
305    ) -> Result<String, String> {
306        AppService::card_samples(self, card_id, offset.unwrap_or(0), limit, where_)
307    }
308
309    async fn card_lineage(
310        &self,
311        card_id: &str,
312        direction: Option<String>,
313        depth: Option<usize>,
314        include_stats: Option<bool>,
315        relation_filter: Option<Vec<String>>,
316    ) -> Result<String, String> {
317        AppService::card_lineage(
318            self,
319            card_id,
320            direction.as_deref(),
321            depth,
322            include_stats,
323            relation_filter,
324        )
325    }
326
327    async fn card_sink_backfill(&self, sink: String, dry_run: bool) -> Result<String, String> {
328        AppService::card_sink_backfill(self, super::card::SinkBackfillParams { sink, dry_run })
329    }
330
331    // ─── Hub ─────────────────────────────────────────────────
332
333    async fn hub_reindex(
334        &self,
335        output_path: Option<String>,
336        source_dir: Option<String>,
337    ) -> Result<String, String> {
338        let svc = self.clone();
339        tokio::task::spawn_blocking(move || {
340            AppService::hub_reindex(&svc, output_path.as_deref(), source_dir.as_deref())
341        })
342        .await
343        .map_err(|e| format!("hub_reindex task panicked: {e}"))?
344    }
345
346    async fn hub_gendoc(
347        &self,
348        source_dir: String,
349        out_dir: Option<String>,
350        projections: Option<Vec<String>>,
351        config_path: Option<String>,
352        lint_strict: Option<bool>,
353    ) -> Result<String, String> {
354        let svc = self.clone();
355        tokio::task::spawn_blocking(move || {
356            crate::AppService::hub_gendoc(
357                &svc,
358                &source_dir,
359                out_dir.as_deref(),
360                projections.as_deref(),
361                config_path.as_deref(),
362                lint_strict,
363            )
364        })
365        .await
366        .map_err(|e| format!("hub_gendoc task panicked: {e}"))?
367    }
368
369    async fn hub_dist(
370        &self,
371        source_dir: String,
372        output_path: Option<String>,
373        out_dir: Option<String>,
374        preset: Option<String>,
375        project_root: Option<String>,
376        projections: Option<Vec<String>>,
377        config_path: Option<String>,
378        lint_strict: Option<bool>,
379    ) -> Result<String, String> {
380        let svc = self.clone();
381        tokio::task::spawn_blocking(move || {
382            AppService::hub_dist(
383                &svc,
384                &source_dir,
385                output_path.as_deref(),
386                out_dir.as_deref(),
387                preset.as_deref(),
388                project_root.as_deref(),
389                projections.as_deref(),
390                config_path.as_deref(),
391                lint_strict,
392            )
393        })
394        .await
395        .map_err(|e| format!("hub_dist task panicked: {e}"))?
396    }
397
398    async fn hub_info(&self, pkg: String) -> Result<String, String> {
399        let svc = self.clone();
400        tokio::task::spawn_blocking(move || AppService::hub_info(&svc, &pkg))
401            .await
402            .map_err(|e| format!("hub_info task panicked: {e}"))?
403    }
404
405    #[allow(clippy::too_many_arguments)]
406    async fn hub_search(
407        &self,
408        query: Option<String>,
409        category: Option<String>,
410        installed_only: Option<bool>,
411        limit: Option<i32>,
412        sort: Option<String>,
413        filter: Option<serde_json::Value>,
414        fields: Option<Vec<String>>,
415        verbose: Option<String>,
416    ) -> Result<String, String> {
417        let svc = self.clone();
418
419        // `filter` is a free-form JSON Value at the MCP boundary (so the
420        // trait stays core-crate-pure). If the caller sends something
421        // that is not a JSON object we treat it as "no filter" — the
422        // explicit category/installed_only params still cover the common
423        // cases. The MCP `JsonSchema` layer will have already flagged
424        // hard type errors. We log the drop so operators can diagnose
425        // unexpected filter shapes in production.
426        let filter_map = match filter {
427            None => None,
428            Some(v) => match serde_json::from_value::<HashMap<String, serde_json::Value>>(v) {
429                Ok(map) => Some(map),
430                Err(e) => {
431                    tracing::warn!(error = %e, "hub_search: filter value is not a JSON object — treating as no filter");
432                    None
433                }
434            },
435        };
436
437        // Negative limit values from MCP callers are clamped to 0 rather
438        // than wrapping to a huge usize (unchecked-user-bound-input pattern).
439        // Downstream semantics: `Some(0)` means "no limit" (return all) —
440        // the truncate path in `AppService::hub_search` short-circuits on 0.
441        let opts = ListOpts {
442            limit: limit.map(|n| n.max(0) as usize),
443            sort,
444            filter: filter_map,
445            fields,
446            verbose,
447        };
448
449        tokio::task::spawn_blocking(move || {
450            AppService::hub_search(
451                &svc,
452                query.as_deref(),
453                category.as_deref(),
454                installed_only,
455                opts,
456            )
457        })
458        .await
459        .map_err(|e| format!("hub_search task panicked: {e}"))?
460    }
461
462    // ─── Package read ─────────────────────────────────────────
463
464    async fn pkg_read_init_lua(&self, name: &str) -> Result<String, String> {
465        AppService::pkg_read_init_lua(self, name, None)
466    }
467
468    async fn pkg_meta(&self, name: &str) -> Result<String, String> {
469        let filter = serde_json::json!({ "name": name });
470        let json_str = EngineApi::pkg_list(
471            self,
472            None,
473            None,
474            None,
475            Some(filter),
476            None,
477            Some("full".to_string()),
478        )
479        .await?;
480        let val: serde_json::Value = serde_json::from_str(&json_str)
481            .map_err(|e| format!("pkg_meta: failed to parse pkg_list response: {e}"))?;
482        let pkgs = val
483            .get("packages")
484            .and_then(|p| p.as_array())
485            .ok_or_else(|| "pkg_meta: pkg_list response missing 'packages' field".to_string())?;
486        if pkgs.is_empty() {
487            return Err(format!("pkg not found: {name}"));
488        }
489        serde_json::to_string(&pkgs[0]).map_err(|e| format!("pkg_meta: serialize entry: {e}"))
490    }
491
492    // ─── Package scaffold ─────────────────────────────────────
493
494    async fn pkg_scaffold(
495        &self,
496        name: String,
497        target_dir: Option<String>,
498        category: Option<String>,
499        description: Option<String>,
500    ) -> Result<String, String> {
501        let svc = self.clone();
502        tokio::task::spawn_blocking(move || {
503            AppService::pkg_scaffold(
504                &svc,
505                &name,
506                target_dir.as_deref(),
507                category.as_deref(),
508                description.as_deref(),
509            )
510        })
511        .await
512        .map_err(|e| format!("pkg_scaffold task panicked: {e}"))?
513    }
514
515    // ─── Diagnostics ─────────────────────────────────────────
516
517    async fn info(&self) -> String {
518        AppService::info(self)
519    }
520}