Skip to main content

algocline_app/service/
card.rs

1//! Card service layer — MCP-facing read/write operations.
2//!
3//! Thin adapter between MCP tool handlers and [`algocline_engine::card`].
4//! All data flows through the engine; this layer handles JSON
5//! serialization for the MCP transport.
6//!
7//! For Card schema, storage layout, and design principles, see
8//! [`algocline_engine::card`] module documentation.
9
10use std::path::Path;
11
12use algocline_engine::card;
13use serde::{Deserialize, Serialize};
14
15use super::hub;
16use super::AppService;
17
18/// Input shape for [`AppService::card_sink_backfill`]. Deserialized from
19/// the Lua/MCP table argument `{ sink, dry_run }`.
20#[derive(Debug, Deserialize)]
21pub struct SinkBackfillParams {
22    pub sink: String,
23    #[serde(default)]
24    pub dry_run: bool,
25}
26
27/// Typed contract for the output produced by a Card analyzer package.
28///
29/// Host-side validation: after `advice()` returns `status == "completed"`,
30/// the `result.result` nested value is deserialized into this struct before
31/// being placed at the top level of the MCP response.  Any package that
32/// cannot produce all required fields (`pattern`, `suggested_change`,
33/// `confidence`) will cause `card_analyze` to return a typed error rather
34/// than passing freeform JSON to the caller.
35///
36/// `failure_count` and `sample_count` are optional so that future analyzer
37/// packages may omit them without breaking the typed contract.
38#[derive(Debug, Serialize, Deserialize)]
39pub struct CardAnalyzeResult {
40    /// One-line summary of the dominant failure pattern.
41    pub pattern: String,
42    /// Concrete improvement suggestion (prompt wording, Lua change, etc.).
43    pub suggested_change: String,
44    /// Analyzer confidence in the finding, clamped to `0.0..=1.0`.
45    pub confidence: f64,
46    /// Number of failure samples detected (optional).
47    #[serde(default)]
48    pub failure_count: Option<u64>,
49    /// Total number of samples evaluated (optional).
50    #[serde(default)]
51    pub sample_count: Option<u64>,
52}
53
54/// Default analyzer package name dispatched from
55/// [`AppService::card_analyze`] when the caller omits `pkg`.
56///
57/// This is an **IF promise**, not a bundled hard dependency: any pkg
58/// (bundled, project-local, or user-installed) named `card_analysis`
59/// that exposes `M.run(ctx) -> ctx` will satisfy it. Not having a pkg
60/// of this name installed surfaces as a normal "package not found"
61/// error from the underlying `advice` dispatch.
62pub const DEFAULT_CARD_ANALYZE_PKG: &str = "card_analysis";
63
64impl AppService {
65    /// List Cards as JSON summaries, optionally filtered by package.
66    pub fn card_list(&self, pkg: Option<&str>) -> Result<String, String> {
67        let rows = self.card_store.list(pkg)?;
68        Ok(card::summaries_to_json(&rows).to_string())
69    }
70
71    /// Fetch full Card body (Tier 1) by id.
72    pub fn card_get(&self, card_id: &str) -> Result<String, String> {
73        match self.card_store.get(card_id)? {
74            Some(v) => Ok(v.to_string()),
75            None => Err(format!("card '{card_id}' not found")),
76        }
77    }
78
79    /// Query Cards using the `where` DSL + `order_by` / limit / offset.
80    pub fn card_find(
81        &self,
82        pkg: Option<String>,
83        where_: Option<serde_json::Value>,
84        order_by: Option<serde_json::Value>,
85        limit: Option<usize>,
86        offset: Option<usize>,
87    ) -> Result<String, String> {
88        let where_parsed = match where_ {
89            Some(v) => Some(card::parse_where(&v)?),
90            None => None,
91        };
92        let order_parsed = match order_by {
93            Some(v) => card::parse_order_by(&v)?,
94            None => Vec::new(),
95        };
96        let q = card::FindQuery {
97            pkg,
98            where_: where_parsed,
99            order_by: order_parsed,
100            limit,
101            offset,
102        };
103        let rows = self.card_store.find(q)?;
104        Ok(card::summaries_to_json(&rows).to_string())
105    }
106
107    /// Resolve alias then fetch the full Card.
108    pub fn card_get_by_alias(&self, name: &str) -> Result<String, String> {
109        match self.card_store.get_by_alias(name)? {
110            Some(v) => Ok(v.to_string()),
111            None => Err(format!("alias '{name}' not found")),
112        }
113    }
114
115    /// List aliases, optionally filtered by package.
116    pub fn card_alias_list(&self, pkg: Option<&str>) -> Result<String, String> {
117        let rows = self.card_store.alias_list(pkg)?;
118        Ok(card::aliases_to_json(&rows).to_string())
119    }
120
121    /// Pin or rebind a mutable alias to a Card.
122    pub fn card_alias_set(
123        &self,
124        name: &str,
125        card_id: &str,
126        pkg: Option<&str>,
127        note: Option<&str>,
128    ) -> Result<String, String> {
129        let alias = self.card_store.alias_set(name, card_id, pkg, note)?;
130        let arr = card::aliases_to_json(std::slice::from_ref(&alias));
131        let single = arr
132            .as_array()
133            .and_then(|a| a.first().cloned())
134            .unwrap_or(serde_json::Value::Null);
135        Ok(single.to_string())
136    }
137
138    /// Additive-only annotation — new top-level keys only.
139    pub fn card_append(&self, card_id: &str, fields: serde_json::Value) -> Result<String, String> {
140        let merged = self.card_store.append(card_id, fields)?;
141        Ok(merged.to_string())
142    }
143
144    /// Install Cards from a Card Collection repo (Git URL or local path).
145    ///
146    /// A Card Collection is identified by `alc_cards.toml` at the repo root.
147    /// Each subdirectory is treated as a package name, and `*.toml` card files
148    /// within are imported into `~/.algocline/cards/{pkg}/`.
149    pub async fn card_install(&self, url: String) -> Result<String, String> {
150        // Local path: import directly
151        let local_path = Path::new(&url);
152        if local_path.is_absolute() && local_path.is_dir() {
153            return self.card_install_from_dir(local_path, &url);
154        }
155
156        // Normalize URL
157        let git_url = if url.starts_with("http://")
158            || url.starts_with("https://")
159            || url.starts_with("file://")
160            || url.starts_with("git@")
161        {
162            url.clone()
163        } else {
164            format!("https://{url}")
165        };
166
167        // Clone to temp directory
168        let staging = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {e}"))?;
169
170        let output = tokio::process::Command::new("git")
171            .args([
172                "clone",
173                "--depth",
174                "1",
175                &git_url,
176                &staging.path().to_string_lossy(),
177            ])
178            .output()
179            .await
180            .map_err(|e| format!("Failed to run git: {e}"))?;
181
182        if !output.status.success() {
183            let stderr = String::from_utf8_lossy(&output.stderr);
184            return Err(format!("git clone failed: {stderr}"));
185        }
186
187        self.card_install_from_dir(staging.path(), &url)
188    }
189
190    /// Import Cards from a local directory (Card Collection or bare cards dir).
191    fn card_install_from_dir(&self, root: &Path, source: &str) -> Result<String, String> {
192        // Verify this is a Card Collection (alc_cards.toml present)
193        let manifest_path = root.join("alc_cards.toml");
194        if !manifest_path.exists() {
195            return Err("Not a Card Collection: alc_cards.toml not found at root. \
196                 Card Collections must have an alc_cards.toml manifest."
197                .into());
198        }
199
200        let mut all_imported: Vec<String> = Vec::new();
201        let mut all_skipped: Vec<String> = Vec::new();
202        let mut packages: Vec<String> = Vec::new();
203
204        let entries =
205            std::fs::read_dir(root).map_err(|e| format!("Failed to read source dir: {e}"))?;
206
207        for entry in entries.flatten() {
208            let path = entry.path();
209            if !path.is_dir() {
210                continue;
211            }
212            let pkg_name = match entry.file_name().to_str() {
213                Some(n) if !n.starts_with('_') && !n.starts_with('.') => n.to_string(),
214                _ => continue,
215            };
216
217            // Check if dir has any .toml files (cards)
218            let has_toml = std::fs::read_dir(&path)
219                .map(|entries| {
220                    entries
221                        .flatten()
222                        .any(|e| e.path().extension().is_some_and(|ext| ext == "toml"))
223                })
224                .unwrap_or(false);
225
226            if !has_toml {
227                continue;
228            }
229
230            let (imported, skipped) =
231                card::import_from_dir_with_store(&*self.card_store, &path, &pkg_name)?;
232            if !imported.is_empty() || !skipped.is_empty() {
233                packages.push(pkg_name);
234            }
235            all_imported.extend(imported);
236            all_skipped.extend(skipped);
237        }
238
239        if all_imported.is_empty() && all_skipped.is_empty() {
240            return Err("No Card files found in any subdirectory.".into());
241        }
242
243        // Register source for Hub index discovery. Storage failure here
244        // surfaces as `storage_warnings` rather than aborting the
245        // import — the Cards themselves are already on disk.
246        let mut storage_warnings: Vec<String> = Vec::new();
247        if let Err(e) = hub::register_source(&self.log_config.app_dir(), source, "card_install") {
248            storage_warnings.push(format!("hub register_source: {e}"));
249        }
250
251        let mut response = serde_json::json!({
252            "installed_cards": all_imported,
253            "skipped_cards": all_skipped,
254            "packages": packages,
255            "source": source,
256            "mode": "card_collection",
257        });
258        if !storage_warnings.is_empty() {
259            response["storage_warnings"] = serde_json::json!(storage_warnings);
260        }
261        Ok(response.to_string())
262    }
263
264    /// Import bundled Cards from a package's `cards/` subdirectory.
265    ///
266    /// Called by `pkg_install` when a package contains a `cards/` dir.
267    /// Returns imported card_ids (may be empty if all were skipped).
268    pub(crate) fn import_pkg_bundled_cards(&self, pkg_name: &str, cards_dir: &Path) -> Vec<String> {
269        match card::import_from_dir_with_store(&*self.card_store, cards_dir, pkg_name) {
270            Ok((imported, _)) => imported,
271            Err(e) => {
272                tracing::warn!("Failed to import bundled cards for '{pkg_name}': {e}");
273                Vec::new()
274            }
275        }
276    }
277
278    /// Read per-case sidecar rows (Tier 2) with `where` filtering and paging.
279    pub fn card_samples(
280        &self,
281        card_id: &str,
282        offset: usize,
283        limit: Option<usize>,
284        where_: Option<serde_json::Value>,
285    ) -> Result<String, String> {
286        let where_parsed = match where_ {
287            Some(v) => Some(card::parse_where(&v)?),
288            None => None,
289        };
290        let q = card::SamplesQuery {
291            offset,
292            limit,
293            where_: where_parsed,
294        };
295        let rows = self.card_store.read_samples(card_id, q)?;
296        Ok(serde_json::Value::Array(rows).to_string())
297    }
298
299    /// Walk a Card's lineage tree via `metadata.prior_card_id`.
300    pub fn card_lineage(
301        &self,
302        card_id: &str,
303        direction: Option<&str>,
304        depth: Option<usize>,
305        include_stats: Option<bool>,
306        relation_filter: Option<Vec<String>>,
307    ) -> Result<String, String> {
308        let dir = match direction {
309            Some(s) => card::LineageDirection::parse(s)?,
310            None => card::LineageDirection::Up,
311        };
312        let q = card::LineageQuery {
313            card_id: card_id.to_string(),
314            direction: dir,
315            depth,
316            include_stats: include_stats.unwrap_or(true),
317            relation_filter,
318        };
319        match self.card_store.lineage(q)? {
320            Some(res) => Ok(card::lineage_to_json(&res).to_string()),
321            None => Err(format!("card '{card_id}' not found")),
322        }
323    }
324
325    /// Backfill one subscriber (`sink` URI) with all cards from the
326    /// primary store. Drift-safe: existing cards on the subscriber
327    /// are skipped, never overwritten. Returns the
328    /// [`card::SinkBackfillReport`] serialized as JSON for MCP
329    /// transport.
330    pub fn card_sink_backfill(&self, params: SinkBackfillParams) -> Result<String, String> {
331        let report = self
332            .card_store
333            .card_sink_backfill(&params.sink, params.dry_run)?;
334        serde_json::to_string(&report)
335            .map_err(|e| format!("failed to serialize SinkBackfillReport: {e}"))
336    }
337
338    /// Load a Card + its samples sidecar and dispatch them to a Lua
339    /// analyzer package.
340    ///
341    /// The host owns Card-schema parsing (Tier 1 body + Tier 2
342    /// `samples.jsonl`) so the analyzer pkg gets a ready-to-use ctx
343    /// shape. The pkg owns prompt construction + `alc.llm` + hint
344    /// formatting.
345    ///
346    /// `pkg` defaults to [`DEFAULT_CARD_ANALYZE_PKG`] when omitted —
347    /// an IF promise, not a bundled hard dependency. The call delegates
348    /// to [`AppService::advice`], so all of `advice`'s machinery
349    /// (auto-install bundled fallback, `start_and_tick`, response
350    /// warning splicing) applies.
351    ///
352    /// ctx shape passed to the pkg's `M.run(ctx)`:
353    /// ```jsonc
354    /// {
355    ///   "card_id": "<id>",
356    ///   "card":    <full Card body, same shape as alc_card_get>,
357    ///   "samples": [<sidecar rows, same shape as alc_card_samples>]
358    /// }
359    /// ```
360    /// The pkg is responsible for filtering failures, building prompts,
361    /// and shaping the result.
362    pub async fn card_analyze(&self, card_id: &str, pkg: Option<String>) -> Result<String, String> {
363        // Tier 1: Card body
364        let card_value = match self.card_store.get(card_id)? {
365            Some(v) => v,
366            None => return Err(format!("card '{card_id}' not found")),
367        };
368
369        // Tier 2: samples sidecar (full read; analyzer pkg filters failures)
370        let samples = self
371            .card_store
372            .read_samples(card_id, card::SamplesQuery::default())?;
373
374        let mut opts = serde_json::Map::new();
375        opts.insert(
376            "card_id".into(),
377            serde_json::Value::String(card_id.to_string()),
378        );
379        opts.insert("card".into(), card_value);
380        opts.insert("samples".into(), serde_json::Value::Array(samples));
381
382        let pkg_name = pkg.as_deref().unwrap_or(DEFAULT_CARD_ANALYZE_PKG);
383        let raw = self
384            .advice(pkg_name, None, Some(serde_json::Value::Object(opts)), None)
385            .await?;
386
387        // Post-process only the final `completed` envelope.  All other
388        // statuses (`needs_response`, `error`, `cancelled`) pass through
389        // unchanged so that the `alc_continue` round-trip is not broken.
390        let mut envelope: serde_json::Value = serde_json::from_str(&raw)
391            .map_err(|e| format!("card_analyze: response is not valid JSON: {e}"))?;
392
393        if envelope.get("status").and_then(serde_json::Value::as_str) == Some("completed") {
394            // Extract `result.result` (the pkg-set ctx field) and validate it
395            // against the typed contract before promoting it to top-level.
396            let inner = envelope
397                .get_mut("result")
398                .ok_or_else(|| {
399                    "card_analyze: completed response missing top-level 'result' field".to_string()
400                })?
401                .get_mut("result")
402                .ok_or_else(|| {
403                    "card_analyze: pkg response missing 'result.result' field".to_string()
404                })?
405                .take();
406
407            let typed: CardAnalyzeResult = serde_json::from_value(inner)
408                .map_err(|e| format!("card_analyze: pkg returned invalid result shape: {e}"))?;
409
410            envelope["result"] = serde_json::to_value(&typed).map_err(|e| {
411                format!("card_analyze: failed to re-serialize CardAnalyzeResult: {e}")
412            })?;
413        }
414
415        serde_json::to_string(&envelope)
416            .map_err(|e| format!("card_analyze: failed to serialize response: {e}"))
417    }
418}