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