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;
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
27impl AppService {
28    /// List Cards as JSON summaries, optionally filtered by package.
29    pub fn card_list(&self, pkg: Option<&str>) -> Result<String, String> {
30        let rows = card::list(pkg)?;
31        Ok(card::summaries_to_json(&rows).to_string())
32    }
33
34    /// Fetch full Card body (Tier 1) by id.
35    pub fn card_get(&self, card_id: &str) -> Result<String, String> {
36        match card::get(card_id)? {
37            Some(v) => Ok(v.to_string()),
38            None => Err(format!("card '{card_id}' not found")),
39        }
40    }
41
42    /// Query Cards using the `where` DSL + `order_by` / limit / offset.
43    pub fn card_find(
44        &self,
45        pkg: Option<String>,
46        where_: Option<serde_json::Value>,
47        order_by: Option<serde_json::Value>,
48        limit: Option<usize>,
49        offset: Option<usize>,
50    ) -> Result<String, String> {
51        let where_parsed = match where_ {
52            Some(v) => Some(card::parse_where(&v)?),
53            None => None,
54        };
55        let order_parsed = match order_by {
56            Some(v) => card::parse_order_by(&v)?,
57            None => Vec::new(),
58        };
59        let q = card::FindQuery {
60            pkg,
61            where_: where_parsed,
62            order_by: order_parsed,
63            limit,
64            offset,
65        };
66        let rows = card::find(q)?;
67        Ok(card::summaries_to_json(&rows).to_string())
68    }
69
70    /// Resolve alias then fetch the full Card.
71    pub fn card_get_by_alias(&self, name: &str) -> Result<String, String> {
72        match card::get_by_alias(name)? {
73            Some(v) => Ok(v.to_string()),
74            None => Err(format!("alias '{name}' not found")),
75        }
76    }
77
78    /// List aliases, optionally filtered by package.
79    pub fn card_alias_list(&self, pkg: Option<&str>) -> Result<String, String> {
80        let rows = card::alias_list(pkg)?;
81        Ok(card::aliases_to_json(&rows).to_string())
82    }
83
84    /// Pin or rebind a mutable alias to a Card.
85    pub fn card_alias_set(
86        &self,
87        name: &str,
88        card_id: &str,
89        pkg: Option<&str>,
90        note: Option<&str>,
91    ) -> Result<String, String> {
92        let alias = card::alias_set(name, card_id, pkg, note)?;
93        let arr = card::aliases_to_json(std::slice::from_ref(&alias));
94        let single = arr
95            .as_array()
96            .and_then(|a| a.first().cloned())
97            .unwrap_or(serde_json::Value::Null);
98        Ok(single.to_string())
99    }
100
101    /// Additive-only annotation — new top-level keys only.
102    pub fn card_append(&self, card_id: &str, fields: serde_json::Value) -> Result<String, String> {
103        let merged = card::append(card_id, fields)?;
104        Ok(merged.to_string())
105    }
106
107    /// Install Cards from a Card Collection repo (Git URL or local path).
108    ///
109    /// A Card Collection is identified by `alc_cards.toml` at the repo root.
110    /// Each subdirectory is treated as a package name, and `*.toml` card files
111    /// within are imported into `~/.algocline/cards/{pkg}/`.
112    pub async fn card_install(&self, url: String) -> Result<String, String> {
113        // Local path: import directly
114        let local_path = Path::new(&url);
115        if local_path.is_absolute() && local_path.is_dir() {
116            return self.card_install_from_dir(local_path, &url);
117        }
118
119        // Normalize URL
120        let git_url = if url.starts_with("http://")
121            || url.starts_with("https://")
122            || url.starts_with("file://")
123            || url.starts_with("git@")
124        {
125            url.clone()
126        } else {
127            format!("https://{url}")
128        };
129
130        // Clone to temp directory
131        let staging = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {e}"))?;
132
133        let output = tokio::process::Command::new("git")
134            .args([
135                "clone",
136                "--depth",
137                "1",
138                &git_url,
139                &staging.path().to_string_lossy(),
140            ])
141            .output()
142            .await
143            .map_err(|e| format!("Failed to run git: {e}"))?;
144
145        if !output.status.success() {
146            let stderr = String::from_utf8_lossy(&output.stderr);
147            return Err(format!("git clone failed: {stderr}"));
148        }
149
150        self.card_install_from_dir(staging.path(), &url)
151    }
152
153    /// Import Cards from a local directory (Card Collection or bare cards dir).
154    fn card_install_from_dir(&self, root: &Path, source: &str) -> Result<String, String> {
155        // Verify this is a Card Collection (alc_cards.toml present)
156        let manifest_path = root.join("alc_cards.toml");
157        if !manifest_path.exists() {
158            return Err("Not a Card Collection: alc_cards.toml not found at root. \
159                 Card Collections must have an alc_cards.toml manifest."
160                .into());
161        }
162
163        let mut all_imported: Vec<String> = Vec::new();
164        let mut all_skipped: Vec<String> = Vec::new();
165        let mut packages: Vec<String> = Vec::new();
166
167        let entries =
168            std::fs::read_dir(root).map_err(|e| format!("Failed to read source dir: {e}"))?;
169
170        for entry in entries.flatten() {
171            let path = entry.path();
172            if !path.is_dir() {
173                continue;
174            }
175            let pkg_name = match entry.file_name().to_str() {
176                Some(n) if !n.starts_with('_') && !n.starts_with('.') => n.to_string(),
177                _ => continue,
178            };
179
180            // Check if dir has any .toml files (cards)
181            let has_toml = std::fs::read_dir(&path)
182                .map(|entries| {
183                    entries
184                        .flatten()
185                        .any(|e| e.path().extension().is_some_and(|ext| ext == "toml"))
186                })
187                .unwrap_or(false);
188
189            if !has_toml {
190                continue;
191            }
192
193            let (imported, skipped) = card::import_from_dir(&path, &pkg_name)?;
194            if !imported.is_empty() || !skipped.is_empty() {
195                packages.push(pkg_name);
196            }
197            all_imported.extend(imported);
198            all_skipped.extend(skipped);
199        }
200
201        if all_imported.is_empty() && all_skipped.is_empty() {
202            return Err("No Card files found in any subdirectory.".into());
203        }
204
205        // Register source for Hub index discovery
206        hub::register_source(source, "card_install");
207
208        let response = serde_json::json!({
209            "installed_cards": all_imported,
210            "skipped_cards": all_skipped,
211            "packages": packages,
212            "source": source,
213            "mode": "card_collection",
214        });
215        Ok(response.to_string())
216    }
217
218    /// Import bundled Cards from a package's `cards/` subdirectory.
219    ///
220    /// Called by `pkg_install` when a package contains a `cards/` dir.
221    /// Returns imported card_ids (may be empty if all were skipped).
222    pub(crate) fn import_pkg_bundled_cards(pkg_name: &str, cards_dir: &Path) -> Vec<String> {
223        match card::import_from_dir(cards_dir, pkg_name) {
224            Ok((imported, _)) => imported,
225            Err(e) => {
226                tracing::warn!("Failed to import bundled cards for '{pkg_name}': {e}");
227                Vec::new()
228            }
229        }
230    }
231
232    /// Read per-case sidecar rows (Tier 2) with `where` filtering and paging.
233    pub fn card_samples(
234        &self,
235        card_id: &str,
236        offset: usize,
237        limit: Option<usize>,
238        where_: Option<serde_json::Value>,
239    ) -> Result<String, String> {
240        let where_parsed = match where_ {
241            Some(v) => Some(card::parse_where(&v)?),
242            None => None,
243        };
244        let q = card::SamplesQuery {
245            offset,
246            limit,
247            where_: where_parsed,
248        };
249        let rows = card::read_samples(card_id, q)?;
250        Ok(serde_json::Value::Array(rows).to_string())
251    }
252
253    /// Walk a Card's lineage tree via `metadata.prior_card_id`.
254    pub fn card_lineage(
255        &self,
256        card_id: &str,
257        direction: Option<&str>,
258        depth: Option<usize>,
259        include_stats: Option<bool>,
260        relation_filter: Option<Vec<String>>,
261    ) -> Result<String, String> {
262        let dir = match direction {
263            Some(s) => card::LineageDirection::parse(s)?,
264            None => card::LineageDirection::Up,
265        };
266        let q = card::LineageQuery {
267            card_id: card_id.to_string(),
268            direction: dir,
269            depth,
270            include_stats: include_stats.unwrap_or(true),
271            relation_filter,
272        };
273        match card::lineage(q)? {
274            Some(res) => Ok(card::lineage_to_json(&res).to_string()),
275            None => Err(format!("card '{card_id}' not found")),
276        }
277    }
278
279    /// Backfill one subscriber (`sink` URI) with all cards from the
280    /// primary store. Drift-safe: existing cards on the subscriber
281    /// are skipped, never overwritten. Returns the
282    /// [`card::SinkBackfillReport`] serialized as JSON for MCP
283    /// transport.
284    pub fn card_sink_backfill(&self, params: SinkBackfillParams) -> Result<String, String> {
285        let report = card::card_sink_backfill(&params.sink, params.dry_run)?;
286        serde_json::to_string(&report)
287            .map_err(|e| format!("failed to serialize SinkBackfillReport: {e}"))
288    }
289}