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