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::error::CardPublishError;
16use super::hub;
17use super::AppService;
18
19/// Input shape for [`AppService::card_sink_backfill`]. Deserialized from
20/// the Lua/MCP table argument `{ sink, dry_run }`.
21#[derive(Debug, Deserialize)]
22pub struct SinkBackfillParams {
23    pub sink: String,
24    #[serde(default)]
25    pub dry_run: bool,
26}
27
28/// Typed contract for the output produced by a Card analyzer package.
29///
30/// Host-side validation: after `advice()` returns `status == "completed"`,
31/// the `result.result` nested value is deserialized into this struct before
32/// being placed at the top level of the MCP response.  Any package that
33/// cannot produce all required fields (`pattern`, `suggested_change`,
34/// `confidence`) will cause `card_analyze` to return a typed error rather
35/// than passing freeform JSON to the caller.
36///
37/// `failure_count` and `sample_count` are optional so that future analyzer
38/// packages may omit them without breaking the typed contract.
39#[derive(Debug, Serialize, Deserialize)]
40pub struct CardAnalyzeResult {
41    /// One-line summary of the dominant failure pattern.
42    pub pattern: String,
43    /// Concrete improvement suggestion (prompt wording, Lua change, etc.).
44    pub suggested_change: String,
45    /// Analyzer confidence in the finding, clamped to `0.0..=1.0`.
46    pub confidence: f64,
47    /// Number of failure samples detected (optional).
48    #[serde(default)]
49    pub failure_count: Option<u64>,
50    /// Total number of samples evaluated (optional).
51    #[serde(default)]
52    pub sample_count: Option<u64>,
53}
54
55/// Default analyzer package name dispatched from
56/// [`AppService::card_analyze`] when the caller omits `pkg`.
57///
58/// This is an **IF promise**, not a bundled hard dependency: any pkg
59/// (bundled, project-local, or user-installed) named `card_analysis`
60/// that exposes `M.run(ctx) -> ctx` will satisfy it. Not having a pkg
61/// of this name installed surfaces as a normal "package not found"
62/// error from the underlying `advice` dispatch.
63pub const DEFAULT_CARD_ANALYZE_PKG: &str = "card_analysis";
64
65impl AppService {
66    /// List Cards as JSON summaries, optionally filtered by package.
67    pub fn card_list(&self, pkg: Option<&str>) -> Result<String, String> {
68        let rows = self.card_store.list(pkg)?;
69        Ok(card::summaries_to_json(&rows).to_string())
70    }
71
72    /// Fetch full Card body (Tier 1) by id.
73    pub fn card_get(&self, card_id: &str) -> Result<String, String> {
74        match self.card_store.get(card_id)? {
75            Some(v) => Ok(v.to_string()),
76            None => Err(format!("card '{card_id}' not found")),
77        }
78    }
79
80    /// Query Cards using the `where` DSL + `order_by` / limit / offset.
81    pub fn card_find(
82        &self,
83        pkg: Option<String>,
84        where_: Option<serde_json::Value>,
85        order_by: Option<serde_json::Value>,
86        limit: Option<usize>,
87        offset: Option<usize>,
88    ) -> Result<String, String> {
89        let where_parsed = match where_ {
90            Some(v) => Some(card::parse_where(&v)?),
91            None => None,
92        };
93        let order_parsed = match order_by {
94            Some(v) => card::parse_order_by(&v)?,
95            None => Vec::new(),
96        };
97        let q = card::FindQuery {
98            pkg,
99            where_: where_parsed,
100            order_by: order_parsed,
101            limit,
102            offset,
103        };
104        let rows = self.card_store.find(q)?;
105        Ok(card::summaries_to_json(&rows).to_string())
106    }
107
108    /// Resolve alias then fetch the full Card.
109    pub fn card_get_by_alias(&self, name: &str) -> Result<String, String> {
110        match self.card_store.get_by_alias(name)? {
111            Some(v) => Ok(v.to_string()),
112            None => Err(format!("alias '{name}' not found")),
113        }
114    }
115
116    /// List aliases, optionally filtered by package.
117    pub fn card_alias_list(&self, pkg: Option<&str>) -> Result<String, String> {
118        let rows = self.card_store.alias_list(pkg)?;
119        Ok(card::aliases_to_json(&rows).to_string())
120    }
121
122    /// Pin or rebind a mutable alias to a Card.
123    pub fn card_alias_set(
124        &self,
125        name: &str,
126        card_id: &str,
127        pkg: Option<&str>,
128        note: Option<&str>,
129    ) -> Result<String, String> {
130        let alias = self.card_store.alias_set(name, card_id, pkg, note)?;
131        let arr = card::aliases_to_json(std::slice::from_ref(&alias));
132        let single = arr
133            .as_array()
134            .and_then(|a| a.first().cloned())
135            .unwrap_or(serde_json::Value::Null);
136        Ok(single.to_string())
137    }
138
139    /// Additive-only annotation — new top-level keys only.
140    pub fn card_append(&self, card_id: &str, fields: serde_json::Value) -> Result<String, String> {
141        let merged = self.card_store.append(card_id, fields)?;
142        Ok(merged.to_string())
143    }
144
145    /// Install Cards from a Card Collection repo (Git URL or local path).
146    ///
147    /// A Card Collection is identified by `alc_cards.toml` at the repo root.
148    /// Each subdirectory is treated as a package name, and `*.toml` card files
149    /// within are imported into `~/.algocline/cards/{pkg}/`.
150    pub async fn card_install(&self, url: String) -> Result<String, String> {
151        // Local path: import directly
152        let local_path = Path::new(&url);
153        if local_path.is_absolute() && local_path.is_dir() {
154            return self.card_install_from_dir(local_path, &url);
155        }
156
157        // Normalize URL
158        let git_url = if url.starts_with("http://")
159            || url.starts_with("https://")
160            || url.starts_with("file://")
161            || url.starts_with("git@")
162        {
163            url.clone()
164        } else {
165            format!("https://{url}")
166        };
167
168        // Clone to temp directory
169        let staging = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {e}"))?;
170
171        let output = tokio::process::Command::new("git")
172            .args([
173                "clone",
174                "--depth",
175                "1",
176                &git_url,
177                &staging.path().to_string_lossy(),
178            ])
179            .output()
180            .await
181            .map_err(|e| format!("Failed to run git: {e}"))?;
182
183        if !output.status.success() {
184            let stderr = String::from_utf8_lossy(&output.stderr);
185            return Err(format!("git clone failed: {stderr}"));
186        }
187
188        self.card_install_from_dir(staging.path(), &url)
189    }
190
191    /// Import Cards from a local directory (Card Collection or bare cards dir).
192    fn card_install_from_dir(&self, root: &Path, source: &str) -> Result<String, String> {
193        // Verify this is a Card Collection (alc_cards.toml present)
194        let manifest_path = root.join("alc_cards.toml");
195        if !manifest_path.exists() {
196            return Err("Not a Card Collection: alc_cards.toml not found at root. \
197                 Card Collections must have an alc_cards.toml manifest."
198                .into());
199        }
200
201        let mut all_imported: Vec<String> = Vec::new();
202        let mut all_skipped: Vec<String> = Vec::new();
203        let mut packages: Vec<String> = Vec::new();
204
205        let entries =
206            std::fs::read_dir(root).map_err(|e| format!("Failed to read source dir: {e}"))?;
207
208        for entry in entries.flatten() {
209            let path = entry.path();
210            if !path.is_dir() {
211                continue;
212            }
213            let pkg_name = match entry.file_name().to_str() {
214                Some(n) if !n.starts_with('_') && !n.starts_with('.') => n.to_string(),
215                _ => continue,
216            };
217
218            // Check if dir has any .toml files (cards)
219            let has_toml = std::fs::read_dir(&path)
220                .map(|entries| {
221                    entries
222                        .flatten()
223                        .any(|e| e.path().extension().is_some_and(|ext| ext == "toml"))
224                })
225                .unwrap_or(false);
226
227            if !has_toml {
228                continue;
229            }
230
231            let (imported, skipped) =
232                card::import_from_dir_with_store(&*self.card_store, &path, &pkg_name)?;
233            if !imported.is_empty() || !skipped.is_empty() {
234                packages.push(pkg_name);
235            }
236            all_imported.extend(imported);
237            all_skipped.extend(skipped);
238        }
239
240        if all_imported.is_empty() && all_skipped.is_empty() {
241            return Err("No Card files found in any subdirectory.".into());
242        }
243
244        // Register source for Hub index discovery. Storage failure here
245        // surfaces as `storage_warnings` rather than aborting the
246        // import — the Cards themselves are already on disk.
247        let mut storage_warnings: Vec<String> = Vec::new();
248        if let Err(e) = hub::register_source(&self.log_config.app_dir(), source, "card_install") {
249            storage_warnings.push(format!("hub register_source: {e}"));
250        }
251
252        let mut response = serde_json::json!({
253            "installed_cards": all_imported,
254            "skipped_cards": all_skipped,
255            "packages": packages,
256            "source": source,
257            "mode": "card_collection",
258        });
259        if !storage_warnings.is_empty() {
260            response["storage_warnings"] = serde_json::json!(storage_warnings);
261        }
262        Ok(response.to_string())
263    }
264
265    /// Import bundled Cards from a package's `cards/` subdirectory.
266    ///
267    /// Called by `pkg_install` when a package contains a `cards/` dir.
268    /// Returns imported card_ids (may be empty if all were skipped).
269    pub(crate) fn import_pkg_bundled_cards(&self, pkg_name: &str, cards_dir: &Path) -> Vec<String> {
270        match card::import_from_dir_with_store(&*self.card_store, cards_dir, pkg_name) {
271            Ok((imported, _)) => imported,
272            Err(e) => {
273                tracing::warn!("Failed to import bundled cards for '{pkg_name}': {e}");
274                Vec::new()
275            }
276        }
277    }
278
279    /// Read per-case sidecar rows (Tier 2) with `where` filtering and paging.
280    pub fn card_samples(
281        &self,
282        card_id: &str,
283        offset: usize,
284        limit: Option<usize>,
285        where_: Option<serde_json::Value>,
286    ) -> Result<String, String> {
287        let where_parsed = match where_ {
288            Some(v) => Some(card::parse_where(&v)?),
289            None => None,
290        };
291        let q = card::SamplesQuery {
292            offset,
293            limit,
294            where_: where_parsed,
295        };
296        let rows = self.card_store.read_samples(card_id, q)?;
297        Ok(serde_json::Value::Array(rows).to_string())
298    }
299
300    /// Walk a Card's lineage tree via `metadata.prior_card_id`.
301    pub fn card_lineage(
302        &self,
303        card_id: &str,
304        direction: Option<&str>,
305        depth: Option<usize>,
306        include_stats: Option<bool>,
307        relation_filter: Option<Vec<String>>,
308    ) -> Result<String, String> {
309        let dir = match direction {
310            Some(s) => card::LineageDirection::parse(s)?,
311            None => card::LineageDirection::Up,
312        };
313        let q = card::LineageQuery {
314            card_id: card_id.to_string(),
315            direction: dir,
316            depth,
317            include_stats: include_stats.unwrap_or(true),
318            relation_filter,
319        };
320        match self.card_store.lineage(q)? {
321            Some(res) => Ok(card::lineage_to_json(&res).to_string()),
322            None => Err(format!("card '{card_id}' not found")),
323        }
324    }
325
326    /// Backfill one subscriber (`sink` URI) with all cards from the
327    /// primary store. Drift-safe: existing cards on the subscriber
328    /// are skipped, never overwritten. Returns the
329    /// [`card::SinkBackfillReport`] serialized as JSON for MCP
330    /// transport.
331    pub fn card_sink_backfill(&self, params: SinkBackfillParams) -> Result<String, String> {
332        let report = self
333            .card_store
334            .card_sink_backfill(&params.sink, params.dry_run)?;
335        serde_json::to_string(&report)
336            .map_err(|e| format!("failed to serialize SinkBackfillReport: {e}"))
337    }
338
339    /// Load a Card + its samples sidecar and dispatch them to a Lua
340    /// analyzer package.
341    ///
342    /// The host owns Card-schema parsing (Tier 1 body + Tier 2
343    /// `samples.jsonl`) so the analyzer pkg gets a ready-to-use ctx
344    /// shape. The pkg owns prompt construction + `alc.llm` + hint
345    /// formatting.
346    ///
347    /// `pkg` defaults to [`DEFAULT_CARD_ANALYZE_PKG`] when omitted —
348    /// an IF promise, not a bundled hard dependency. The call delegates
349    /// to [`AppService::advice`], so all of `advice`'s machinery
350    /// (auto-install bundled fallback, `start_and_tick`, response
351    /// warning splicing) applies.
352    ///
353    /// ctx shape passed to the pkg's `M.run(ctx)`:
354    /// ```jsonc
355    /// {
356    ///   "card_id": "<id>",
357    ///   "card":    <full Card body, same shape as alc_card_get>,
358    ///   "samples": [<sidecar rows, same shape as alc_card_samples>]
359    /// }
360    /// ```
361    /// The pkg is responsible for filtering failures, building prompts,
362    /// and shaping the result.
363    pub async fn card_analyze(&self, card_id: &str, pkg: Option<String>) -> Result<String, String> {
364        // Tier 1: Card body
365        let card_value = match self.card_store.get(card_id)? {
366            Some(v) => v,
367            None => return Err(format!("card '{card_id}' not found")),
368        };
369
370        // Tier 2: samples sidecar (full read; analyzer pkg filters failures)
371        let samples = self
372            .card_store
373            .read_samples(card_id, card::SamplesQuery::default())?;
374
375        let mut opts = serde_json::Map::new();
376        opts.insert(
377            "card_id".into(),
378            serde_json::Value::String(card_id.to_string()),
379        );
380        opts.insert("card".into(), card_value);
381        opts.insert("samples".into(), serde_json::Value::Array(samples));
382
383        let pkg_name = pkg.as_deref().unwrap_or(DEFAULT_CARD_ANALYZE_PKG);
384        let raw = self
385            .advice(pkg_name, None, Some(serde_json::Value::Object(opts)), None)
386            .await?;
387
388        // Post-process only the final `completed` envelope.  All other
389        // statuses (`needs_response`, `error`, `cancelled`) pass through
390        // unchanged so that the `alc_continue` round-trip is not broken.
391        let mut envelope: serde_json::Value = serde_json::from_str(&raw)
392            .map_err(|e| format!("card_analyze: response is not valid JSON: {e}"))?;
393
394        if envelope.get("status").and_then(serde_json::Value::as_str) == Some("completed") {
395            // Extract `result.result` (the pkg-set ctx field) and validate it
396            // against the typed contract before promoting it to top-level.
397            let inner = envelope
398                .get_mut("result")
399                .ok_or_else(|| {
400                    "card_analyze: completed response missing top-level 'result' field".to_string()
401                })?
402                .get_mut("result")
403                .ok_or_else(|| {
404                    "card_analyze: pkg response missing 'result.result' field".to_string()
405                })?
406                .take();
407
408            let typed: CardAnalyzeResult = serde_json::from_value(inner)
409                .map_err(|e| format!("card_analyze: pkg returned invalid result shape: {e}"))?;
410
411            envelope["result"] = serde_json::to_value(&typed).map_err(|e| {
412                format!("card_analyze: failed to re-serialize CardAnalyzeResult: {e}")
413            })?;
414        }
415
416        serde_json::to_string(&envelope)
417            .map_err(|e| format!("card_analyze: failed to serialize response: {e}"))
418    }
419
420    /// Publish a Card to a hub repository.
421    ///
422    /// Validates `target_repo` as a URL, clones it to a staging directory,
423    /// copies the card files, commits, and pushes.  On push success, calls
424    /// `hub_reindex` and returns the outcome as a JSON string including
425    /// `published_url`, `commit_hash`, and `reindex_status`.
426    ///
427    /// Push failures due to credential issues return a typed
428    /// `CardPublishError::MissingCredentials` with actionable guidance.
429    /// Reindex failures are reported in the response JSON, not as errors,
430    /// so a successful push is never rolled back.
431    pub async fn card_publish(
432        &self,
433        card_id: &str,
434        target_repo: &str,
435        commit_message: Option<&str>,
436    ) -> Result<String, String> {
437        self.card_publish_inner(card_id, target_repo, commit_message)
438            .await
439            .map_err(|e| e.to_string())
440    }
441
442    async fn card_publish_inner(
443        &self,
444        card_id: &str,
445        target_repo: &str,
446        commit_message: Option<&str>,
447    ) -> Result<String, CardPublishError> {
448        // 1. Validate target_repo is a URL (pkg slug not yet supported)
449        if !is_supported_target(target_repo) {
450            return Err(CardPublishError::InvalidTarget(format!(
451                "{target_repo} — must be a URL (http/https/file/git@/ssh). \
452                pkg slug resolution is not yet supported; see issue #1.",
453            )));
454        }
455
456        // 2. Resolve card from store
457        let card_value = self
458            .card_store
459            .get(card_id)
460            .map_err(|e| CardPublishError::GitCommand {
461                cmd: "card_store.get".into(),
462                stderr: e,
463            })?
464            .ok_or_else(|| CardPublishError::CardNotFound(card_id.to_string()))?;
465
466        // 3. Extract pkg name from card value
467        let pkg_name = card_value
468            .get("pkg")
469            .and_then(|v| v.get("name"))
470            .and_then(|v| v.as_str())
471            .map(|s| s.to_string())
472            .unwrap_or_else(|| "unknown".to_string());
473
474        // 4. Clone target_repo to staging
475        let staging = tempfile::tempdir()?;
476        let staging_str = staging
477            .path()
478            .to_str()
479            .ok_or_else(|| {
480                CardPublishError::InvalidTarget("staging path is not valid UTF-8".into())
481            })?
482            .to_string();
483
484        if let Err((stderr, is_credential)) =
485            run_git_command(&["clone", "--depth", "1", target_repo, &staging_str], None).await
486        {
487            if is_credential {
488                let app_dir_path = self.log_config.app_dir().root().to_owned();
489                let report = tokio::task::spawn_blocking(move || {
490                    crate::service::gh_credentials::diagnose(&app_dir_path)
491                })
492                .await
493                .map_err(|e| CardPublishError::GitCommand {
494                    cmd: "spawn_blocking(diagnose)".into(),
495                    stderr: e.to_string(),
496                })?;
497                let guidance = crate::service::gh_credentials::build_guidance(&report);
498                return Err(CardPublishError::MissingCredentials { guidance });
499            } else {
500                return Err(CardPublishError::GitCommand {
501                    cmd: "clone".into(),
502                    stderr,
503                });
504            }
505        }
506
507        // 5. Copy card files into staging/cards/{pkg}/
508        let dest_dir = staging.path().join("cards").join(&pkg_name);
509        std::fs::create_dir_all(&dest_dir)?;
510
511        // Collect card files: {card_id}.toml and optionally {card_id}.samples.jsonl
512        // card_store.root() is already the cards dir (e.g. ~/.algocline/cards)
513        let cards_root = self.card_store.root().join(&pkg_name);
514        let card_toml = cards_root.join(format!("{card_id}.toml"));
515        let card_samples = cards_root.join(format!("{card_id}.samples.jsonl"));
516
517        if card_toml.exists() {
518            std::fs::copy(&card_toml, dest_dir.join(format!("{card_id}.toml")))?;
519        } else {
520            return Err(CardPublishError::CardNotFound(card_id.to_string()));
521        }
522        if card_samples.exists() {
523            std::fs::copy(
524                &card_samples,
525                dest_dir.join(format!("{card_id}.samples.jsonl")),
526            )?;
527        }
528
529        // 6. git add
530        run_git_command(&["add", "."], Some(staging.path()))
531            .await
532            .map_err(|(stderr, _)| CardPublishError::GitCommand {
533                cmd: "add".into(),
534                stderr,
535            })?;
536
537        // 7. git commit
538        let msg = commit_message
539            .map(String::from)
540            .unwrap_or_else(|| format!("publish card {card_id}"));
541        run_git_command(&["commit", "-m", &msg], Some(staging.path()))
542            .await
543            .map_err(|(stderr, _)| CardPublishError::GitCommand {
544                cmd: "commit".into(),
545                stderr,
546            })?;
547
548        // 8. git rev-parse HEAD — get commit hash
549        let commit_hash = run_git_output(&["rev-parse", "HEAD"], Some(staging.path()))
550            .await
551            .map_err(|stderr| CardPublishError::GitCommand {
552                cmd: "rev-parse HEAD".into(),
553                stderr,
554            })?
555            .trim()
556            .to_string();
557
558        // 9. git push — detect credential failures here.
559        //    diagnose() uses sync std::process::Command internally (logging.rs
560        //    compatibility), so wrap in spawn_blocking to avoid blocking the
561        //    tokio worker thread.
562        if let Err((stderr, is_credential)) =
563            run_git_command(&["push", "origin", "HEAD"], Some(staging.path())).await
564        {
565            if is_credential {
566                let app_dir_path = self.log_config.app_dir().root().to_owned();
567                let report = tokio::task::spawn_blocking(move || {
568                    crate::service::gh_credentials::diagnose(&app_dir_path)
569                })
570                .await
571                .map_err(|e| CardPublishError::GitCommand {
572                    cmd: "spawn_blocking(diagnose)".into(),
573                    stderr: e.to_string(),
574                })?;
575                let guidance = crate::service::gh_credentials::build_guidance(&report);
576                return Err(CardPublishError::MissingCredentials { guidance });
577            } else {
578                return Err(CardPublishError::GitCommand {
579                    cmd: "push".into(),
580                    stderr,
581                });
582            }
583        }
584
585        // 10. hub_reindex — failure absorbed into reindex_status (not bubbled as Err)
586        let reindex_status = match self.hub_reindex(None, None).await {
587            Ok(out) => ReindexStatus {
588                ok: true,
589                output: Some(out),
590                error: None,
591            },
592            Err(e) => ReindexStatus {
593                ok: false,
594                output: None,
595                error: Some(e),
596            },
597        };
598
599        // 11. Build response
600        let outcome = CardPublishOutcome {
601            published_url: target_repo.to_string(),
602            commit_hash,
603            reindex_status,
604        };
605        serde_json::to_string(&outcome).map_err(|e| CardPublishError::GitCommand {
606            cmd: "serialize response".into(),
607            stderr: e.to_string(),
608        })
609    }
610}
611
612// ─── Private response types ───────────────────────────────────────
613
614#[derive(Debug, Serialize)]
615struct CardPublishOutcome {
616    published_url: String,
617    commit_hash: String,
618    reindex_status: ReindexStatus,
619}
620
621#[derive(Debug, Serialize)]
622struct ReindexStatus {
623    ok: bool,
624    #[serde(skip_serializing_if = "Option::is_none")]
625    output: Option<String>,
626    #[serde(skip_serializing_if = "Option::is_none")]
627    error: Option<String>,
628}
629
630// ─── Private helpers ──────────────────────────────────────────────
631
632/// Returns `true` when `target_repo` starts with a recognized URL scheme.
633fn is_supported_target(target_repo: &str) -> bool {
634    target_repo.starts_with("http://")
635        || target_repo.starts_with("https://")
636        || target_repo.starts_with("file://")
637        || target_repo.starts_with("git@")
638        || target_repo.starts_with("ssh://")
639}
640
641/// Returns `true` when `stderr` matches known credential-failure patterns.
642fn detect_credential_error(stderr: &str) -> bool {
643    let patterns = [
644        "Permission denied (publickey)",
645        "Authentication failed",
646        "remote: Permission to",
647        "could not read Username",
648        "terminal prompts disabled",
649        "gh auth login",
650    ];
651    patterns.iter().any(|p| stderr.contains(p))
652}
653
654/// Run a git command optionally in a working directory.
655///
656/// Returns `Ok(())` on success.
657/// Returns `Err((stderr, is_credential_error))` on failure.
658async fn run_git_command(args: &[&str], cwd: Option<&Path>) -> Result<(), (String, bool)> {
659    let mut cmd = tokio::process::Command::new("git");
660    cmd.args(args).env("GIT_TERMINAL_PROMPT", "0");
661    if let Some(dir) = cwd {
662        cmd.current_dir(dir);
663    }
664    let output = cmd.output().await.map_err(|e| (e.to_string(), false))?;
665    if output.status.success() {
666        Ok(())
667    } else {
668        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
669        let is_cred = detect_credential_error(&stderr);
670        Err((stderr, is_cred))
671    }
672}
673
674/// Run a git command in a working directory and capture stdout.
675///
676/// Returns `Ok(stdout)` on success, `Err(stderr)` on failure.
677async fn run_git_output(args: &[&str], cwd: Option<&Path>) -> Result<String, String> {
678    let mut cmd = tokio::process::Command::new("git");
679    cmd.args(args).env("GIT_TERMINAL_PROMPT", "0");
680    if let Some(dir) = cwd {
681        cmd.current_dir(dir);
682    }
683    let output = cmd.output().await.map_err(|e| e.to_string())?;
684    if output.status.success() {
685        Ok(String::from_utf8_lossy(&output.stdout).to_string())
686    } else {
687        Err(String::from_utf8_lossy(&output.stderr).to_string())
688    }
689}
690
691// ─── Tests ────────────────────────────────────────────────────────
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696
697    #[test]
698    fn detect_credential_error_matches_publickey_denied() {
699        assert!(detect_credential_error(
700            "git@github.com: Permission denied (publickey).\nfatal: Could not read from remote repository."
701        ));
702    }
703
704    #[test]
705    fn detect_credential_error_matches_authentication_failed() {
706        assert!(detect_credential_error(
707            "remote: Authentication failed for 'https://github.com/user/repo.git'"
708        ));
709    }
710
711    #[test]
712    fn detect_credential_error_returns_false_for_unrelated_stderr() {
713        assert!(!detect_credential_error(
714            "fatal: pathspec 'cards/cot/foo.toml' did not match any files known to git"
715        ));
716    }
717
718    #[test]
719    fn is_supported_target_accepts_https() {
720        assert!(is_supported_target("https://github.com/user/repo.git"));
721    }
722
723    #[test]
724    fn is_supported_target_accepts_git_at() {
725        assert!(is_supported_target("git@github.com:user/repo.git"));
726    }
727
728    #[test]
729    fn is_supported_target_accepts_file_url() {
730        assert!(is_supported_target("file:///tmp/bare-repo"));
731    }
732
733    #[test]
734    fn is_supported_target_rejects_bare_slug() {
735        assert!(!is_supported_target("cot"));
736        assert!(!is_supported_target("my-pkg"));
737    }
738}