Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/bin/stacker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1041,9 +1041,9 @@ fn get_command(
.with_force_new(force_new)
.with_runtime(runtime),
),
StackerCommands::Connect { handoff } => Box::new(
stacker::console::commands::cli::connect::ConnectCommand::new(handoff),
),
StackerCommands::Connect { handoff } => {
Box::new(stacker::console::commands::cli::connect::ConnectCommand::new(handoff))
}
StackerCommands::Logs {
service,
follow,
Expand Down
37 changes: 27 additions & 10 deletions src/cli/ai_field_matcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,10 @@ impl AiFieldMatcher {
prompt
}

fn parse_response(&self, response: &str) -> Option<(HashMap<String, (String, f32)>, Vec<TransformSuggestion>)> {
fn parse_response(
&self,
response: &str,
) -> Option<(HashMap<String, (String, f32)>, Vec<TransformSuggestion>)> {
// Try to extract JSON from the response (may be wrapped in markdown code blocks)
let json_str = extract_json_block(response);
let parsed: serde_json::Value = serde_json::from_str(json_str).ok()?;
Expand Down Expand Up @@ -136,8 +139,15 @@ impl FieldMatcher for AiFieldMatcher {
let response = match self.provider.complete(FIELD_MATCH_SYSTEM_PROMPT, &prompt) {
Ok(r) => r,
Err(e) => {
eprintln!(" ⚠ AI field matching failed ({}), falling back to deterministic", e);
return DeterministicFieldMatcher.match_fields(src_fields, tgt_fields, source_sample);
eprintln!(
" ⚠ AI field matching failed ({}), falling back to deterministic",
e
);
return DeterministicFieldMatcher.match_fields(
src_fields,
tgt_fields,
source_sample,
);
}
};

Expand All @@ -147,10 +157,7 @@ impl FieldMatcher for AiFieldMatcher {
let mut confidence = HashMap::new();

for (target, (source, conf)) in &field_mappings {
mapping.insert(
target.clone(),
serde_json::Value::String(source.clone()),
);
mapping.insert(target.clone(), serde_json::Value::String(source.clone()));
confidence.insert(target.clone(), *conf);
}

Expand Down Expand Up @@ -228,7 +235,11 @@ mod tests {
);
let matcher = AiFieldMatcher::from_provider(Box::new(mock), "test-model".to_string());

let src = vec!["user_email".to_string(), "display_name".to_string(), "id".to_string()];
let src = vec![
"user_email".to_string(),
"display_name".to_string(),
"id".to_string(),
];
let tgt = vec!["email".to_string(), "name".to_string()];
let result = matcher.match_fields(&src, &tgt, None);

Expand All @@ -247,7 +258,11 @@ mod tests {
);
let matcher = AiFieldMatcher::from_provider(Box::new(mock), "test-model".to_string());

let src = vec!["mail".to_string(), "first_name".to_string(), "last_name".to_string()];
let src = vec![
"mail".to_string(),
"first_name".to_string(),
"last_name".to_string(),
];
let tgt = vec!["email".to_string(), "full_name".to_string()];
let result = matcher.match_fields(&src, &tgt, None);

Expand Down Expand Up @@ -291,7 +306,9 @@ mod tests {
fn test_ai_field_match_fallback_on_provider_error() {
struct FailingProvider;
impl AiProvider for FailingProvider {
fn name(&self) -> &str { "failing" }
fn name(&self) -> &str {
"failing"
}
fn complete(&self, _: &str, _: &str) -> Result<String, CliError> {
Err(CliError::AiProviderError {
provider: "failing".to_string(),
Expand Down
19 changes: 15 additions & 4 deletions src/cli/ai_pipe_suggest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,18 @@ impl AiPipeSuggest {
) -> Result<Vec<PipeSuggestion>, CliError> {
let prompt = self.build_prompt(source_app, target_app, source_endpoints, target_endpoints);

let response = self.provider.complete(PIPE_SUGGEST_SYSTEM_PROMPT, &prompt)?;
let response = self
.provider
.complete(PIPE_SUGGEST_SYSTEM_PROMPT, &prompt)?;

match self.parse_response(&response) {
Some(mut suggestions) => {
// Sort by confidence descending
suggestions.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap_or(std::cmp::Ordering::Equal));
suggestions.sort_by(|a, b| {
b.confidence
.partial_cmp(&a.confidence)
.unwrap_or(std::cmp::Ordering::Equal)
});
Ok(suggestions)
}
None => {
Expand Down Expand Up @@ -133,7 +139,10 @@ fn parse_endpoint_info(val: &serde_json::Value) -> Option<EndpointInfo> {
Some(EndpointInfo {
method: val.get("method")?.as_str()?.to_string(),
path: val.get("path")?.as_str()?.to_string(),
description: val.get("description").and_then(|d| d.as_str()).map(|s| s.to_string()),
description: val
.get("description")
.and_then(|d| d.as_str())
.map(|s| s.to_string()),
fields: None,
})
}
Expand Down Expand Up @@ -240,7 +249,9 @@ mod tests {
fn test_suggest_error_propagation() {
struct FailingProvider;
impl AiProvider for FailingProvider {
fn name(&self) -> &str { "failing" }
fn name(&self) -> &str {
"failing"
}
fn complete(&self, _: &str, _: &str) -> Result<String, CliError> {
Err(CliError::AiProviderError {
provider: "failing".to_string(),
Expand Down
119 changes: 90 additions & 29 deletions src/cli/ai_scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,21 +231,25 @@ fn discover_local_pipe_hints(
.map(|c| c.to_lowercase())
.unwrap_or_default();

let mut push_hint = |target: &str, kind: &str, confidence: PipeHintConfidence, evidence: Vec<String>| {
if evidence.is_empty() {
return;
}
hints.push(PipeHint {
source: project_name.to_string(),
target: target.to_string(),
kind: kind.to_string(),
confidence,
evidence,
});
};
let mut push_hint =
|target: &str, kind: &str, confidence: PipeHintConfidence, evidence: Vec<String>| {
if evidence.is_empty() {
return;
}
hints.push(PipeHint {
source: project_name.to_string(),
target: target.to_string(),
kind: kind.to_string(),
confidence,
evidence,
});
};

let mut webhook_evidence = Vec::new();
if package_json.contains("webhook") || requirements.contains("webhook") || pyproject.contains("webhook") {
if package_json.contains("webhook")
|| requirements.contains("webhook")
|| pyproject.contains("webhook")
{
webhook_evidence.push("webhook-related dependency detected".to_string());
}
if lower_env_keys.iter().any(|k| k.contains("webhook")) {
Expand All @@ -258,29 +262,50 @@ fn discover_local_pipe_hints(
webhook_evidence.push("env keys reference Discord integration".to_string());
}
if !webhook_evidence.is_empty() {
push_hint("external-webhook", "webhook", PipeHintConfidence::Medium, webhook_evidence);
push_hint(
"external-webhook",
"webhook",
PipeHintConfidence::Medium,
webhook_evidence,
);
}

let mut postgres_evidence = Vec::new();
if compose.contains("postgres") {
postgres_evidence.push("compose references postgres".to_string());
}
if lower_env_keys.iter().any(|k| k == "database_url" || k.contains("postgres")) {
if lower_env_keys
.iter()
.any(|k| k == "database_url" || k.contains("postgres"))
{
postgres_evidence.push("env keys reference postgres/database".to_string());
}
if !postgres_evidence.is_empty() {
push_hint("postgres", "database", PipeHintConfidence::High, postgres_evidence);
push_hint(
"postgres",
"database",
PipeHintConfidence::High,
postgres_evidence,
);
}

let mut redis_evidence = Vec::new();
if compose.contains("redis") {
redis_evidence.push("compose references redis".to_string());
}
if lower_env_keys.iter().any(|k| k == "redis_url" || k.contains("redis")) {
if lower_env_keys
.iter()
.any(|k| k == "redis_url" || k.contains("redis"))
{
redis_evidence.push("env keys reference redis".to_string());
}
if !redis_evidence.is_empty() {
push_hint("redis", "cache-or-queue", PipeHintConfidence::High, redis_evidence);
push_hint(
"redis",
"cache-or-queue",
PipeHintConfidence::High,
redis_evidence,
);
}

let mut qdrant_evidence = Vec::new();
Expand All @@ -291,34 +316,63 @@ fn discover_local_pipe_hints(
qdrant_evidence.push("env keys reference qdrant".to_string());
}
if !qdrant_evidence.is_empty() {
push_hint("qdrant", "vector-store", PipeHintConfidence::High, qdrant_evidence);
push_hint(
"qdrant",
"vector-store",
PipeHintConfidence::High,
qdrant_evidence,
);
}

let mut llm_evidence = Vec::new();
if package_json.contains("openai") || requirements.contains("openai") || pyproject.contains("openai") {
if package_json.contains("openai")
|| requirements.contains("openai")
|| pyproject.contains("openai")
{
llm_evidence.push("OpenAI dependency detected".to_string());
}
if package_json.contains("anthropic") || requirements.contains("anthropic") || pyproject.contains("anthropic") {
if package_json.contains("anthropic")
|| requirements.contains("anthropic")
|| pyproject.contains("anthropic")
{
llm_evidence.push("Anthropic dependency detected".to_string());
}
if compose.contains("ollama") || lower_env_keys.iter().any(|k| k.contains("ollama")) {
llm_evidence.push("local Ollama usage detected".to_string());
}
if !llm_evidence.is_empty() {
push_hint("llm-provider", "ai-provider", PipeHintConfidence::Medium, llm_evidence);
push_hint(
"llm-provider",
"ai-provider",
PipeHintConfidence::Medium,
llm_evidence,
);
}

let mut frontend_api_evidence = Vec::new();
let looks_like_frontend = detected_app_type == "node"
&& root_files.iter().any(|f| f == "next.config.js" || f == "next.config.mjs" || f == "vite.config.ts" || f == "vite.config.js");
&& root_files.iter().any(|f| {
f == "next.config.js"
|| f == "next.config.mjs"
|| f == "vite.config.ts"
|| f == "vite.config.js"
});
if looks_like_frontend {
frontend_api_evidence.push("frontend framework config detected".to_string());
}
if lower_env_keys.iter().any(|k| k.contains("api_url") || k.contains("api_base") || k.contains("backend_url")) {
if lower_env_keys
.iter()
.any(|k| k.contains("api_url") || k.contains("api_base") || k.contains("backend_url"))
{
frontend_api_evidence.push("env keys reference backend/api URL".to_string());
}
if !frontend_api_evidence.is_empty() {
push_hint("backend-api", "http-api", PipeHintConfidence::Medium, frontend_api_evidence);
push_hint(
"backend-api",
"http-api",
PipeHintConfidence::Medium,
frontend_api_evidence,
);
}

hints
Expand Down Expand Up @@ -664,7 +718,8 @@ mod tests {
let mut file_contents = HashMap::new();
file_contents.insert(
"docker-compose.yml".to_string(),
"services:\n postgres:\n image: postgres:16\n redis:\n image: redis:7\n".to_string(),
"services:\n postgres:\n image: postgres:16\n redis:\n image: redis:7\n"
.to_string(),
);

let hints = discover_local_pipe_hints(
Expand All @@ -675,8 +730,12 @@ mod tests {
&["DATABASE_URL".to_string(), "REDIS_URL".to_string()],
);

assert!(hints.iter().any(|h| h.target == "postgres" && h.kind == "database"));
assert!(hints.iter().any(|h| h.target == "redis" && h.kind == "cache-or-queue"));
assert!(hints
.iter()
.any(|h| h.target == "postgres" && h.kind == "database"));
assert!(hints
.iter()
.any(|h| h.target == "redis" && h.kind == "cache-or-queue"));
}

#[test]
Expand All @@ -689,7 +748,9 @@ mod tests {
&["NEXT_PUBLIC_API_URL".to_string()],
);

assert!(hints.iter().any(|h| h.target == "backend-api" && h.kind == "http-api"));
assert!(hints
.iter()
.any(|h| h.target == "backend-api" && h.kind == "http-api"));
}

// ── build_generation_prompt ─────────────────────
Expand Down
6 changes: 4 additions & 2 deletions src/connectors/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@
//! }
//! ```

pub mod app_service_catalog;
pub mod admin_service;
pub mod app_service_catalog;
pub mod config;
pub mod dockerhub_service;
pub mod errors;
Expand All @@ -60,9 +60,11 @@ pub use user_service::{
};

// Re-export init functions for convenient access
pub use app_service_catalog::{
fetch_catalog as fetch_app_service_catalog, resolve_server_capacity, ServerCapacity,
};
pub use dockerhub_service::init as init_dockerhub;
pub use dockerhub_service::{
DockerHubClient, DockerHubConnector, NamespaceSummary, RepositorySummary, TagSummary,
};
pub use user_service::init as init_user_service;
pub use app_service_catalog::{fetch_catalog as fetch_app_service_catalog, resolve_server_capacity, ServerCapacity};
Loading
Loading