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 home = crate::service::config::AppConfig::resolve_home();
509                let report = tokio::task::spawn_blocking(move || {
510                    crate::service::gh_credentials::diagnose(&app_dir_path, home.as_deref())
511                })
512                .await
513                .map_err(|e| CardPublishError::GitCommand {
514                    cmd: "spawn_blocking(diagnose)".into(),
515                    stderr: e.to_string(),
516                })?;
517                let guidance = crate::service::gh_credentials::build_guidance(&report);
518                return Err(CardPublishError::MissingCredentials { guidance });
519            } else {
520                return Err(CardPublishError::GitCommand {
521                    cmd: "clone".into(),
522                    stderr,
523                });
524            }
525        }
526
527        // 5. Copy card files into staging/cards/{pkg}/
528        let dest_dir = staging.path().join("cards").join(&pkg_name);
529        std::fs::create_dir_all(&dest_dir)?;
530
531        // Collect card files: {card_id}.toml and optionally {card_id}.samples.jsonl
532        // card_store.root() is already the cards dir (e.g. ~/.algocline/cards)
533        let cards_root = self.card_store.root().join(&pkg_name);
534        let card_toml = cards_root.join(format!("{card_id}.toml"));
535        let card_samples = cards_root.join(format!("{card_id}.samples.jsonl"));
536
537        if card_toml.exists() {
538            std::fs::copy(&card_toml, dest_dir.join(format!("{card_id}.toml")))?;
539        } else {
540            return Err(CardPublishError::CardNotFound(card_id.to_string()));
541        }
542        if card_samples.exists() {
543            std::fs::copy(
544                &card_samples,
545                dest_dir.join(format!("{card_id}.samples.jsonl")),
546            )?;
547        }
548
549        // 6. git add
550        run_git_command(&["add", "."], Some(staging.path()))
551            .await
552            .map_err(|(stderr, _)| CardPublishError::GitCommand {
553                cmd: "add".into(),
554                stderr,
555            })?;
556
557        // 7. git commit
558        let msg = commit_message
559            .map(String::from)
560            .unwrap_or_else(|| format!("publish card {card_id}"));
561        run_git_command(&["commit", "-m", &msg], Some(staging.path()))
562            .await
563            .map_err(|(stderr, _)| CardPublishError::GitCommand {
564                cmd: "commit".into(),
565                stderr,
566            })?;
567
568        // 8. git rev-parse HEAD — get commit hash
569        let commit_hash = run_git_output(&["rev-parse", "HEAD"], Some(staging.path()))
570            .await
571            .map_err(|stderr| CardPublishError::GitCommand {
572                cmd: "rev-parse HEAD".into(),
573                stderr,
574            })?
575            .trim()
576            .to_string();
577
578        // 9. git push — detect credential failures here.
579        //    diagnose() uses sync std::process::Command internally (logging.rs
580        //    compatibility), so wrap in spawn_blocking to avoid blocking the
581        //    tokio worker thread.
582        if let Err((stderr, is_credential)) =
583            run_git_command(&["push", "origin", "HEAD"], Some(staging.path())).await
584        {
585            if is_credential {
586                let app_dir_path = self.log_config.app_dir().root().to_owned();
587                let home = crate::service::config::AppConfig::resolve_home();
588                let report = tokio::task::spawn_blocking(move || {
589                    crate::service::gh_credentials::diagnose(&app_dir_path, home.as_deref())
590                })
591                .await
592                .map_err(|e| CardPublishError::GitCommand {
593                    cmd: "spawn_blocking(diagnose)".into(),
594                    stderr: e.to_string(),
595                })?;
596                let guidance = crate::service::gh_credentials::build_guidance(&report);
597                return Err(CardPublishError::MissingCredentials { guidance });
598            } else {
599                return Err(CardPublishError::GitCommand {
600                    cmd: "push".into(),
601                    stderr,
602                });
603            }
604        }
605
606        // 10. hub_reindex — failure absorbed into reindex_status (not bubbled as Err)
607        let reindex_status = match self.hub_reindex(None, None).await {
608            Ok(out) => ReindexStatus {
609                ok: true,
610                output: Some(out),
611                error: None,
612            },
613            Err(e) => ReindexStatus {
614                ok: false,
615                output: None,
616                error: Some(e),
617            },
618        };
619
620        // 11. Build response
621        let outcome = CardPublishOutcome {
622            published_url: target_repo.to_string(),
623            commit_hash,
624            reindex_status,
625        };
626        serde_json::to_string(&outcome).map_err(|e| CardPublishError::GitCommand {
627            cmd: "serialize response".into(),
628            stderr: e.to_string(),
629        })
630    }
631}
632
633// ─── Private response types ───────────────────────────────────────
634
635#[derive(Debug, Serialize)]
636struct CardPublishOutcome {
637    published_url: String,
638    commit_hash: String,
639    reindex_status: ReindexStatus,
640}
641
642#[derive(Debug, Serialize)]
643struct ReindexStatus {
644    ok: bool,
645    #[serde(skip_serializing_if = "Option::is_none")]
646    output: Option<String>,
647    #[serde(skip_serializing_if = "Option::is_none")]
648    error: Option<String>,
649}
650
651// ─── Private helpers ──────────────────────────────────────────────
652
653/// Returns `true` when `target_repo` starts with a recognized URL scheme.
654fn is_supported_target(target_repo: &str) -> bool {
655    target_repo.starts_with("http://")
656        || target_repo.starts_with("https://")
657        || target_repo.starts_with("file://")
658        || target_repo.starts_with("git@")
659        || target_repo.starts_with("ssh://")
660}
661
662/// Returns `true` when `stderr` matches known credential-failure patterns.
663fn detect_credential_error(stderr: &str) -> bool {
664    let patterns = [
665        "Permission denied (publickey)",
666        "Authentication failed",
667        "remote: Permission to",
668        "could not read Username",
669        "terminal prompts disabled",
670        "gh auth login",
671    ];
672    patterns.iter().any(|p| stderr.contains(p))
673}
674
675/// Run a git command optionally in a working directory.
676///
677/// Returns `Ok(())` on success.
678/// Returns `Err((stderr, is_credential_error))` on failure.
679async fn run_git_command(args: &[&str], cwd: Option<&Path>) -> Result<(), (String, bool)> {
680    let mut cmd = tokio::process::Command::new("git");
681    cmd.args(args).env("GIT_TERMINAL_PROMPT", "0");
682    if let Some(dir) = cwd {
683        cmd.current_dir(dir);
684    }
685    let output = cmd.output().await.map_err(|e| (e.to_string(), false))?;
686    if output.status.success() {
687        Ok(())
688    } else {
689        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
690        let is_cred = detect_credential_error(&stderr);
691        Err((stderr, is_cred))
692    }
693}
694
695/// Run a git command in a working directory and capture stdout.
696///
697/// Returns `Ok(stdout)` on success, `Err(stderr)` on failure.
698async fn run_git_output(args: &[&str], cwd: Option<&Path>) -> Result<String, String> {
699    let mut cmd = tokio::process::Command::new("git");
700    cmd.args(args).env("GIT_TERMINAL_PROMPT", "0");
701    if let Some(dir) = cwd {
702        cmd.current_dir(dir);
703    }
704    let output = cmd.output().await.map_err(|e| e.to_string())?;
705    if output.status.success() {
706        Ok(String::from_utf8_lossy(&output.stdout).to_string())
707    } else {
708        Err(String::from_utf8_lossy(&output.stderr).to_string())
709    }
710}
711
712// ─── Tests ────────────────────────────────────────────────────────
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717
718    #[test]
719    fn detect_credential_error_matches_publickey_denied() {
720        assert!(detect_credential_error(
721            "git@github.com: Permission denied (publickey).\nfatal: Could not read from remote repository."
722        ));
723    }
724
725    #[test]
726    fn detect_credential_error_matches_authentication_failed() {
727        assert!(detect_credential_error(
728            "remote: Authentication failed for 'https://github.com/user/repo.git'"
729        ));
730    }
731
732    #[test]
733    fn detect_credential_error_returns_false_for_unrelated_stderr() {
734        assert!(!detect_credential_error(
735            "fatal: pathspec 'cards/cot/foo.toml' did not match any files known to git"
736        ));
737    }
738
739    #[test]
740    fn is_supported_target_accepts_https() {
741        assert!(is_supported_target("https://github.com/user/repo.git"));
742    }
743
744    #[test]
745    fn is_supported_target_accepts_git_at() {
746        assert!(is_supported_target("git@github.com:user/repo.git"));
747    }
748
749    #[test]
750    fn is_supported_target_accepts_file_url() {
751        assert!(is_supported_target("file:///tmp/bare-repo"));
752    }
753
754    #[test]
755    fn is_supported_target_rejects_bare_slug() {
756        assert!(!is_supported_target("cot"));
757        assert!(!is_supported_target("my-pkg"));
758    }
759}