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