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