1use 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#[derive(Debug, Deserialize)]
22pub struct SinkBackfillParams {
23 pub sink: String,
24 #[serde(default)]
25 pub dry_run: bool,
26}
27
28#[derive(Debug, Serialize, Deserialize)]
40pub struct CardAnalyzeResult {
41 pub pattern: String,
43 pub suggested_change: String,
45 pub confidence: f64,
47 #[serde(default)]
49 pub failure_count: Option<u64>,
50 #[serde(default)]
52 pub sample_count: Option<u64>,
53}
54
55pub const DEFAULT_CARD_ANALYZE_PKG: &str = "card_analysis";
64
65impl AppService {
66 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 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 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 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 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 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 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 pub async fn card_install(&self, url: String) -> Result<String, String> {
151 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 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 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 fn card_install_from_dir(&self, root: &Path, source: &str) -> Result<String, String> {
193 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 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 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 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 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 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 pub fn card_sink_backfill(&self, params: SinkBackfillParams) -> Result<String, String> {
332 let report = self
333 .card_store
334 .card_sink_backfill(¶ms.sink, params.dry_run)?;
335 serde_json::to_string(&report)
336 .map_err(|e| format!("failed to serialize SinkBackfillReport: {e}"))
337 }
338
339 pub async fn card_analyze(&self, card_id: &str, pkg: Option<String>) -> Result<String, String> {
364 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 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 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 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 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 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 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 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 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 let dest_dir = staging.path().join("cards").join(&pkg_name);
509 std::fs::create_dir_all(&dest_dir)?;
510
511 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 run_git_command(&["add", "."], Some(staging.path()))
531 .await
532 .map_err(|(stderr, _)| CardPublishError::GitCommand {
533 cmd: "add".into(),
534 stderr,
535 })?;
536
537 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 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 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 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 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#[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
630fn 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
641fn 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
654async 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
674async 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#[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}