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