use std::path::Path;
use algocline_engine::card;
use algocline_engine::card::CardStore;
use serde::{Deserialize, Serialize};
use super::error::CardPublishError;
use super::hub;
use super::AppService;
#[derive(Debug, Deserialize)]
pub struct SinkBackfillParams {
pub sink: String,
#[serde(default)]
pub dry_run: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CardAnalyzeResult {
pub pattern: String,
pub suggested_change: String,
pub confidence: f64,
#[serde(default)]
pub failure_count: Option<u64>,
#[serde(default)]
pub sample_count: Option<u64>,
}
pub const DEFAULT_CARD_ANALYZE_PKG: &str = "card_analysis";
impl AppService {
pub fn card_list(&self, pkg: Option<&str>) -> Result<String, String> {
let rows = self.card_store.list(pkg)?;
Ok(card::summaries_to_json(&rows).to_string())
}
pub fn card_get(&self, card_id: &str) -> Result<String, String> {
match self.card_store.get(card_id)? {
Some(v) => Ok(v.to_string()),
None => Err(format!("card '{card_id}' not found")),
}
}
pub fn card_find(
&self,
pkg: Option<String>,
where_: Option<serde_json::Value>,
order_by: Option<serde_json::Value>,
limit: Option<usize>,
offset: Option<usize>,
) -> Result<String, String> {
let where_parsed = match where_ {
Some(v) => Some(card::parse_where(&v)?),
None => None,
};
let order_parsed = match order_by {
Some(v) => card::parse_order_by(&v)?,
None => Vec::new(),
};
let q = card::FindQuery {
pkg,
where_: where_parsed,
order_by: order_parsed,
limit,
offset,
};
let rows = self.card_store.find(q)?;
Ok(card::summaries_to_json(&rows).to_string())
}
pub fn card_get_by_alias(&self, name: &str) -> Result<String, String> {
match self.card_store.get_by_alias(name)? {
Some(v) => Ok(v.to_string()),
None => Err(format!("alias '{name}' not found")),
}
}
pub fn card_alias_list(&self, pkg: Option<&str>) -> Result<String, String> {
let rows = self.card_store.alias_list(pkg)?;
Ok(card::aliases_to_json(&rows).to_string())
}
pub fn card_alias_set(
&self,
name: &str,
card_id: &str,
pkg: Option<&str>,
note: Option<&str>,
) -> Result<String, String> {
let alias = self.card_store.alias_set(name, card_id, pkg, note)?;
let arr = card::aliases_to_json(std::slice::from_ref(&alias));
let single = arr
.as_array()
.and_then(|a| a.first().cloned())
.unwrap_or(serde_json::Value::Null);
Ok(single.to_string())
}
pub fn card_append(&self, card_id: &str, fields: serde_json::Value) -> Result<String, String> {
let merged = self.card_store.append(card_id, fields)?;
Ok(merged.to_string())
}
pub async fn card_install(&self, url: String) -> Result<String, String> {
let local_path = Path::new(&url);
if local_path.is_absolute() && local_path.is_dir() {
return self.card_install_from_dir(local_path, &url);
}
let git_url = if url.starts_with("http://")
|| url.starts_with("https://")
|| url.starts_with("file://")
|| url.starts_with("git@")
{
url.clone()
} else {
format!("https://{url}")
};
let staging = tempfile::tempdir().map_err(|e| format!("Failed to create temp dir: {e}"))?;
let output = tokio::process::Command::new("git")
.args([
"clone",
"--depth",
"1",
&git_url,
&staging.path().to_string_lossy(),
])
.output()
.await
.map_err(|e| format!("Failed to run git: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git clone failed: {stderr}"));
}
self.card_install_from_dir(staging.path(), &url)
}
fn card_install_from_dir(&self, root: &Path, source: &str) -> Result<String, String> {
let manifest_path = root.join("alc_cards.toml");
if !manifest_path.exists() {
return Err("Not a Card Collection: alc_cards.toml not found at root. \
Card Collections must have an alc_cards.toml manifest."
.into());
}
let mut all_imported: Vec<String> = Vec::new();
let mut all_skipped: Vec<String> = Vec::new();
let mut packages: Vec<String> = Vec::new();
let entries =
std::fs::read_dir(root).map_err(|e| format!("Failed to read source dir: {e}"))?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let pkg_name = match entry.file_name().to_str() {
Some(n) if !n.starts_with('_') && !n.starts_with('.') => n.to_string(),
_ => continue,
};
let has_toml = std::fs::read_dir(&path)
.map(|entries| {
entries
.flatten()
.any(|e| e.path().extension().is_some_and(|ext| ext == "toml"))
})
.unwrap_or(false);
if !has_toml {
continue;
}
let (imported, skipped) =
card::import_from_dir_with_store(&*self.card_store, &path, &pkg_name)?;
if !imported.is_empty() || !skipped.is_empty() {
packages.push(pkg_name);
}
all_imported.extend(imported);
all_skipped.extend(skipped);
}
if all_imported.is_empty() && all_skipped.is_empty() {
return Err("No Card files found in any subdirectory.".into());
}
let mut storage_warnings: Vec<String> = Vec::new();
if let Err(e) = hub::register_source(&self.log_config.app_dir(), source, "card_install") {
storage_warnings.push(format!("hub register_source: {e}"));
}
let mut response = serde_json::json!({
"installed_cards": all_imported,
"skipped_cards": all_skipped,
"packages": packages,
"source": source,
"mode": "card_collection",
});
if !storage_warnings.is_empty() {
response["storage_warnings"] = serde_json::json!(storage_warnings);
}
Ok(response.to_string())
}
pub(crate) fn import_pkg_bundled_cards(&self, pkg_name: &str, cards_dir: &Path) -> Vec<String> {
match card::import_from_dir_with_store(&*self.card_store, cards_dir, pkg_name) {
Ok((imported, _)) => imported,
Err(e) => {
tracing::warn!("Failed to import bundled cards for '{pkg_name}': {e}");
Vec::new()
}
}
}
pub fn card_samples(
&self,
card_id: &str,
offset: usize,
limit: Option<usize>,
where_: Option<serde_json::Value>,
) -> Result<String, String> {
let where_parsed = match where_ {
Some(v) => Some(card::parse_where(&v)?),
None => None,
};
let q = card::SamplesQuery {
offset,
limit,
where_: where_parsed,
};
let rows = self.card_store.read_samples(card_id, q)?;
Ok(serde_json::Value::Array(rows).to_string())
}
pub fn card_lineage(
&self,
card_id: &str,
direction: Option<&str>,
depth: Option<usize>,
include_stats: Option<bool>,
relation_filter: Option<Vec<String>>,
) -> Result<String, String> {
let dir = match direction {
Some(s) => card::LineageDirection::parse(s)?,
None => card::LineageDirection::Up,
};
let q = card::LineageQuery {
card_id: card_id.to_string(),
direction: dir,
depth,
include_stats: include_stats.unwrap_or(true),
relation_filter,
};
match self.card_store.lineage(q)? {
Some(res) => Ok(card::lineage_to_json(&res).to_string()),
None => Err(format!("card '{card_id}' not found")),
}
}
pub fn card_sink_backfill(&self, params: SinkBackfillParams) -> Result<String, String> {
let report = self
.card_store
.card_sink_backfill(¶ms.sink, params.dry_run)?;
serde_json::to_string(&report)
.map_err(|e| format!("failed to serialize SinkBackfillReport: {e}"))
}
pub async fn card_analyze(&self, card_id: &str, pkg: Option<String>) -> Result<String, String> {
let card_value = match self.card_store.get(card_id)? {
Some(v) => v,
None => return Err(format!("card '{card_id}' not found")),
};
let samples = self
.card_store
.read_samples(card_id, card::SamplesQuery::default())?;
let mut opts = serde_json::Map::new();
opts.insert(
"card_id".into(),
serde_json::Value::String(card_id.to_string()),
);
opts.insert("card".into(), card_value);
opts.insert("samples".into(), serde_json::Value::Array(samples));
let pkg_name = pkg.as_deref().unwrap_or(DEFAULT_CARD_ANALYZE_PKG);
let raw = self
.advice(pkg_name, None, Some(serde_json::Value::Object(opts)), None)
.await?;
let mut envelope: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| format!("card_analyze: response is not valid JSON: {e}"))?;
if envelope.get("status").and_then(serde_json::Value::as_str) == Some("completed") {
let inner = envelope
.get_mut("result")
.ok_or_else(|| {
"card_analyze: completed response missing top-level 'result' field".to_string()
})?
.get_mut("result")
.ok_or_else(|| {
"card_analyze: pkg response missing 'result.result' field".to_string()
})?
.take();
let typed: CardAnalyzeResult = serde_json::from_value(inner)
.map_err(|e| format!("card_analyze: pkg returned invalid result shape: {e}"))?;
envelope["result"] = serde_json::to_value(&typed).map_err(|e| {
format!("card_analyze: failed to re-serialize CardAnalyzeResult: {e}")
})?;
}
serde_json::to_string(&envelope)
.map_err(|e| format!("card_analyze: failed to serialize response: {e}"))
}
pub async fn card_publish(
&self,
card_id: &str,
target_repo: &str,
commit_message: Option<&str>,
) -> Result<String, String> {
self.card_publish_inner(card_id, target_repo, commit_message)
.await
.map_err(|e| e.to_string())
}
async fn card_publish_inner(
&self,
card_id: &str,
target_repo: &str,
commit_message: Option<&str>,
) -> Result<String, CardPublishError> {
if !is_supported_target(target_repo) {
return Err(CardPublishError::InvalidTarget(format!(
"{target_repo} — must be a URL (http/https/file/git@/ssh). \
pkg slug resolution is not yet supported; see issue #1.",
)));
}
let card_value = self
.card_store
.get(card_id)
.map_err(|e| CardPublishError::GitCommand {
cmd: "card_store.get".into(),
stderr: e,
})?
.ok_or_else(|| CardPublishError::CardNotFound(card_id.to_string()))?;
let _ = card_value; let locator = self
.card_store
.find_card_locator(card_id)
.map_err(|e| CardPublishError::GitCommand {
cmd: "card_store.find_card_locator".into(),
stderr: e,
})?
.ok_or_else(|| CardPublishError::CardNotFound(card_id.to_string()))?;
let pkg_name = locator
.parent()
.and_then(|p| p.file_name())
.and_then(|s| s.to_str())
.ok_or_else(|| {
CardPublishError::InvalidTarget(format!(
"card {card_id} locator has no parent pkg directory: {}",
locator.display()
))
})?
.to_string();
algocline_engine::card::validate_name(&pkg_name, "pkg")
.map_err(CardPublishError::InvalidTarget)?;
let staging = tempfile::tempdir()?;
let staging_str = staging
.path()
.to_str()
.ok_or_else(|| {
CardPublishError::InvalidTarget("staging path is not valid UTF-8".into())
})?
.to_string();
if let Err((stderr, is_credential)) =
run_git_command(&["clone", "--depth", "1", target_repo, &staging_str], None).await
{
if is_credential {
let app_dir_path = self.log_config.app_dir().root().to_owned();
let report = tokio::task::spawn_blocking(move || {
crate::service::gh_credentials::diagnose(&app_dir_path)
})
.await
.map_err(|e| CardPublishError::GitCommand {
cmd: "spawn_blocking(diagnose)".into(),
stderr: e.to_string(),
})?;
let guidance = crate::service::gh_credentials::build_guidance(&report);
return Err(CardPublishError::MissingCredentials { guidance });
} else {
return Err(CardPublishError::GitCommand {
cmd: "clone".into(),
stderr,
});
}
}
let dest_dir = staging.path().join("cards").join(&pkg_name);
std::fs::create_dir_all(&dest_dir)?;
let cards_root = self.card_store.root().join(&pkg_name);
let card_toml = cards_root.join(format!("{card_id}.toml"));
let card_samples = cards_root.join(format!("{card_id}.samples.jsonl"));
if card_toml.exists() {
std::fs::copy(&card_toml, dest_dir.join(format!("{card_id}.toml")))?;
} else {
return Err(CardPublishError::CardNotFound(card_id.to_string()));
}
if card_samples.exists() {
std::fs::copy(
&card_samples,
dest_dir.join(format!("{card_id}.samples.jsonl")),
)?;
}
run_git_command(&["add", "."], Some(staging.path()))
.await
.map_err(|(stderr, _)| CardPublishError::GitCommand {
cmd: "add".into(),
stderr,
})?;
let msg = commit_message
.map(String::from)
.unwrap_or_else(|| format!("publish card {card_id}"));
run_git_command(&["commit", "-m", &msg], Some(staging.path()))
.await
.map_err(|(stderr, _)| CardPublishError::GitCommand {
cmd: "commit".into(),
stderr,
})?;
let commit_hash = run_git_output(&["rev-parse", "HEAD"], Some(staging.path()))
.await
.map_err(|stderr| CardPublishError::GitCommand {
cmd: "rev-parse HEAD".into(),
stderr,
})?
.trim()
.to_string();
if let Err((stderr, is_credential)) =
run_git_command(&["push", "origin", "HEAD"], Some(staging.path())).await
{
if is_credential {
let app_dir_path = self.log_config.app_dir().root().to_owned();
let report = tokio::task::spawn_blocking(move || {
crate::service::gh_credentials::diagnose(&app_dir_path)
})
.await
.map_err(|e| CardPublishError::GitCommand {
cmd: "spawn_blocking(diagnose)".into(),
stderr: e.to_string(),
})?;
let guidance = crate::service::gh_credentials::build_guidance(&report);
return Err(CardPublishError::MissingCredentials { guidance });
} else {
return Err(CardPublishError::GitCommand {
cmd: "push".into(),
stderr,
});
}
}
let reindex_status = match self.hub_reindex(None, None).await {
Ok(out) => ReindexStatus {
ok: true,
output: Some(out),
error: None,
},
Err(e) => ReindexStatus {
ok: false,
output: None,
error: Some(e),
},
};
let outcome = CardPublishOutcome {
published_url: target_repo.to_string(),
commit_hash,
reindex_status,
};
serde_json::to_string(&outcome).map_err(|e| CardPublishError::GitCommand {
cmd: "serialize response".into(),
stderr: e.to_string(),
})
}
}
#[derive(Debug, Serialize)]
struct CardPublishOutcome {
published_url: String,
commit_hash: String,
reindex_status: ReindexStatus,
}
#[derive(Debug, Serialize)]
struct ReindexStatus {
ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
output: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
fn is_supported_target(target_repo: &str) -> bool {
target_repo.starts_with("http://")
|| target_repo.starts_with("https://")
|| target_repo.starts_with("file://")
|| target_repo.starts_with("git@")
|| target_repo.starts_with("ssh://")
}
fn detect_credential_error(stderr: &str) -> bool {
let patterns = [
"Permission denied (publickey)",
"Authentication failed",
"remote: Permission to",
"could not read Username",
"terminal prompts disabled",
"gh auth login",
];
patterns.iter().any(|p| stderr.contains(p))
}
async fn run_git_command(args: &[&str], cwd: Option<&Path>) -> Result<(), (String, bool)> {
let mut cmd = tokio::process::Command::new("git");
cmd.args(args).env("GIT_TERMINAL_PROMPT", "0");
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
let output = cmd.output().await.map_err(|e| (e.to_string(), false))?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let is_cred = detect_credential_error(&stderr);
Err((stderr, is_cred))
}
}
async fn run_git_output(args: &[&str], cwd: Option<&Path>) -> Result<String, String> {
let mut cmd = tokio::process::Command::new("git");
cmd.args(args).env("GIT_TERMINAL_PROMPT", "0");
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
let output = cmd.output().await.map_err(|e| e.to_string())?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
Err(String::from_utf8_lossy(&output.stderr).to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_credential_error_matches_publickey_denied() {
assert!(detect_credential_error(
"git@github.com: Permission denied (publickey).\nfatal: Could not read from remote repository."
));
}
#[test]
fn detect_credential_error_matches_authentication_failed() {
assert!(detect_credential_error(
"remote: Authentication failed for 'https://github.com/user/repo.git'"
));
}
#[test]
fn detect_credential_error_returns_false_for_unrelated_stderr() {
assert!(!detect_credential_error(
"fatal: pathspec 'cards/cot/foo.toml' did not match any files known to git"
));
}
#[test]
fn is_supported_target_accepts_https() {
assert!(is_supported_target("https://github.com/user/repo.git"));
}
#[test]
fn is_supported_target_accepts_git_at() {
assert!(is_supported_target("git@github.com:user/repo.git"));
}
#[test]
fn is_supported_target_accepts_file_url() {
assert!(is_supported_target("file:///tmp/bare-repo"));
}
#[test]
fn is_supported_target_rejects_bare_slug() {
assert!(!is_supported_target("cot"));
assert!(!is_supported_target("my-pkg"));
}
}