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 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 let dest_dir = staging.path().join("cards").join(&pkg_name);
529 std::fs::create_dir_all(&dest_dir)?;
530
531 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 run_git_command(&["add", "."], Some(staging.path()))
551 .await
552 .map_err(|(stderr, _)| CardPublishError::GitCommand {
553 cmd: "add".into(),
554 stderr,
555 })?;
556
557 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 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 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 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 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#[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
651fn 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
662fn 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
675async 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
695async 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#[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}