1use 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#[derive(Debug, Deserialize)]
23pub struct SinkBackfillParams {
24 pub sink: String,
25 #[serde(default)]
26 pub dry_run: bool,
27}
28
29#[derive(Debug, Serialize, Deserialize)]
41pub struct CardAnalyzeResult {
42 pub pattern: String,
44 pub suggested_change: String,
46 pub confidence: f64,
48 #[serde(default)]
50 pub failure_count: Option<u64>,
51 #[serde(default)]
53 pub sample_count: Option<u64>,
54}
55
56pub const DEFAULT_CARD_ANALYZE_PKG: &str = "card_analysis";
65
66impl AppService {
67 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 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 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 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 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 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 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 pub async fn card_install(&self, url: String) -> Result<String, String> {
152 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 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 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 fn card_install_from_dir(&self, root: &Path, source: &str) -> Result<String, String> {
194 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 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 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 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 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 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 pub fn card_sink_backfill(&self, params: SinkBackfillParams) -> Result<String, String> {
333 let report = self
334 .card_store
335 .card_sink_backfill(¶ms.sink, params.dry_run)?;
336 serde_json::to_string(&report)
337 .map_err(|e| format!("failed to serialize SinkBackfillReport: {e}"))
338 }
339
340 pub async fn card_analyze(&self, card_id: &str, pkg: Option<String>) -> Result<String, String> {
365 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 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 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 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 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 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 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 let _ = card_value; 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 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 let dest_dir = staging.path().join("cards").join(&pkg_name);
528 std::fs::create_dir_all(&dest_dir)?;
529
530 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 run_git_command(&["add", "."], Some(staging.path()))
550 .await
551 .map_err(|(stderr, _)| CardPublishError::GitCommand {
552 cmd: "add".into(),
553 stderr,
554 })?;
555
556 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 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 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 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 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#[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
649fn 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
660fn 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
673async 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
693async 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#[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}