use std::path::{Path, PathBuf};
use std::collections::HashSet;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ForgeProjectSummary {
pub name: String,
pub version: String,
pub path: String,
pub file_path: String,
pub is_root: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RecipeInfo {
pub name: String,
pub description: Option<String>,
pub depends_on: Option<Vec<String>>,
pub server: Option<String>,
pub action: Option<String>,
pub commands: Vec<String>,
pub health_check: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct StageInfo {
pub name: String,
pub recipes: Vec<String>,
pub parallel: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PipelineInfo {
pub name: String,
pub description: Option<String>,
pub stages: Vec<StageInfo>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ForgeProjectDetails {
pub name: String,
pub version: String,
pub path: String,
pub recipes: Vec<RecipeInfo>,
pub pipelines: Vec<PipelineInfo>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RunResult {
pub success: bool,
pub exit_code: Option<i32>,
pub stdout: String,
pub stderr: String,
pub duration_ms: u64,
}
pub fn discover_all_forge_projects() -> Vec<ForgeProjectSummary> {
let mut discovered = Vec::new();
let mut seen_paths = HashSet::new();
let mut candidate_roots = Vec::new();
if let Ok(cur) = std::env::current_dir() {
candidate_roots.push(cur);
}
if let Ok(exe) = std::env::current_exe() {
if let Some(parent) = exe.parent() {
candidate_roots.push(parent.to_path_buf());
}
}
for start in candidate_roots {
let mut curr = Some(start);
let mut depth = 0;
while let Some(dir) = curr {
if depth > 5 {
break;
}
check_and_add_forge_toml(&dir, &mut discovered, &mut seen_paths);
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
check_and_add_forge_toml(&path, &mut discovered, &mut seen_paths);
let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if dir_name == "tools" || dir_name == "packages" || dir_name == "apps" {
if let Ok(sub_entries) = std::fs::read_dir(&path) {
for sub_entry in sub_entries.flatten() {
let sub_path = sub_entry.path();
if sub_path.is_dir() {
check_and_add_forge_toml(&sub_path, &mut discovered, &mut seen_paths);
}
}
}
}
}
}
}
curr = dir.parent().map(|p| p.to_path_buf());
depth += 1;
}
}
discovered.sort_by(|a, b| {
b.is_root.cmp(&a.is_root).then_with(|| a.name.cmp(&b.name))
});
discovered
}
fn check_and_add_forge_toml(
dir: &Path,
discovered: &mut Vec<ForgeProjectSummary>,
seen_paths: &mut HashSet<PathBuf>,
) {
let forge_file = dir.join("Forge.toml");
if forge_file.exists() && forge_file.is_file() {
let canonical = forge_file.canonicalize().unwrap_or_else(|_| forge_file.clone());
if seen_paths.insert(canonical.clone()) {
if let Ok(content) = std::fs::read_to_string(&forge_file) {
if let Ok(parsed) = toml::from_str::<toml::Value>(&content) {
let name = parsed.get("forge")
.and_then(|f| f.get("name"))
.and_then(|n| n.as_str())
.unwrap_or_else(|| dir.file_name().and_then(|n| n.to_str()).unwrap_or("Unbenannt"))
.to_string();
let version = parsed.get("forge")
.and_then(|f| f.get("version"))
.and_then(|v| v.as_str())
.unwrap_or("1.0.0")
.to_string();
let is_root = name.contains("workspace")
|| parsed.get("recipe").and_then(|r| r.as_table()).map(|t| {
t.values().any(|v| {
v.get("run").map(|r| r.to_string().contains("cd ")).unwrap_or(false)
})
}).unwrap_or(false);
discovered.push(ForgeProjectSummary {
name,
version,
path: dir.to_string_lossy().to_string(),
file_path: canonical.to_string_lossy().to_string(),
is_root,
});
}
}
}
}
}
#[tauri::command]
pub fn list_forge_projects() -> Result<Vec<ForgeProjectSummary>, String> {
Ok(discover_all_forge_projects())
}
#[tauri::command]
pub fn load_forge_project(project_path: Option<String>) -> Result<ForgeProjectDetails, String> {
let target_dir = if let Some(p) = project_path {
let p_buf = PathBuf::from(&p);
if p_buf.join("Forge.toml").exists() {
p_buf
} else if p_buf.file_name().and_then(|n| n.to_str()) == Some("Forge.toml") && p_buf.exists() {
p_buf.parent().unwrap_or(&p_buf).to_path_buf()
} else {
let projects = discover_all_forge_projects();
if let Some(found) = projects.iter().find(|pr| pr.path == p || pr.file_path == p) {
PathBuf::from(&found.path)
} else {
return Err(format!("Keine Forge.toml unter '{}' gefunden", p));
}
}
} else {
let projects = discover_all_forge_projects();
if let Some(first) = projects.first() {
PathBuf::from(&first.path)
} else {
return Err("Keine Forge.toml im aktuellen Verzeichnis, Parent oder Nachbar-Ordnern gefunden.".to_string());
}
};
let file_path = target_dir.join("Forge.toml");
let content = std::fs::read_to_string(&file_path)
.map_err(|e| format!("Forge.toml in '{}' konnte nicht gelesen werden: {}", target_dir.display(), e))?;
let parsed: toml::Value = toml::from_str(&content)
.map_err(|e| format!("Forge.toml Syntax-Fehler: {}", e))?;
let name = parsed.get("forge")
.and_then(|f| f.get("name"))
.and_then(|n| n.as_str())
.unwrap_or("Unbenannt")
.to_string();
let version = parsed.get("forge")
.and_then(|f| f.get("version"))
.and_then(|v| v.as_str())
.unwrap_or("0.1.0")
.to_string();
let mut recipes = Vec::new();
if let Some(r_table) = parsed.get("recipe").and_then(|r| r.as_table()) {
for (r_name, r_val) in r_table {
let description = r_val.get("description").and_then(|d| d.as_str()).map(String::from);
let server = r_val.get("server").and_then(|s| s.as_str()).map(String::from);
let action = r_val.get("action").and_then(|a| a.as_str()).map(String::from);
let health_check = r_val.get("health_check").and_then(|h| h.as_str()).map(String::from);
let depends_on = r_val.get("depends_on")
.and_then(|d| d.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect());
let mut commands = Vec::new();
if let Some(run_val) = r_val.get("run") {
if let Some(single) = run_val.as_str() {
commands.push(single.to_string());
} else if let Some(arr) = run_val.as_array() {
for v in arr {
if let Some(s) = v.as_str() {
commands.push(s.to_string());
}
}
}
}
recipes.push(RecipeInfo {
name: r_name.clone(),
description,
depends_on,
server,
action,
commands,
health_check,
});
}
}
recipes.sort_by(|a, b| a.name.cmp(&b.name));
let mut pipelines = Vec::new();
if let Some(p_table) = parsed.get("pipeline").and_then(|p| p.as_table()) {
for (p_name, p_val) in p_table {
let description = p_val.get("description").and_then(|d| d.as_str()).map(String::from);
let mut stages = Vec::new();
if let Some(stages_arr) = p_val.get("stages").and_then(|s| s.as_array()) {
for stage_val in stages_arr {
let s_name = stage_val.get("name").and_then(|n| n.as_str()).unwrap_or("Stage").to_string();
let parallel = stage_val.get("parallel").and_then(|p| p.as_bool()).unwrap_or(false);
let mut s_recipes = Vec::new();
if let Some(arr) = stage_val.get("recipes").and_then(|r| r.as_array()) {
for item in arr {
if let Some(s) = item.as_str() {
s_recipes.push(s.to_string());
}
}
}
stages.push(StageInfo {
name: s_name,
recipes: s_recipes,
parallel,
});
}
}
pipelines.push(PipelineInfo {
name: p_name.clone(),
description,
stages,
});
}
}
pipelines.sort_by(|a, b| a.name.cmp(&b.name));
Ok(ForgeProjectDetails {
name,
version,
path: target_dir.to_string_lossy().to_string(),
recipes,
pipelines,
})
}
#[tauri::command]
pub async fn execute_forge_target(
project_path: String,
target: String,
env: Option<String>,
state: tauri::State<'_, crate::vault::VaultState>,
) -> Result<RunResult, String> {
crate::vault::ensure_unlocked(&state)?;
let sb_forge = which::which("sb-forge")
.or_else(|_| {
dirs::home_dir().map(|h| h.join(".cargo").join("bin").join(if cfg!(windows) { "sb-forge.exe" } else { "sb-forge" }))
.filter(|p| p.exists())
.ok_or("Nicht gefunden")
})
.map_err(|_| "sb-forge Binary nicht gefunden".to_string())?;
let mut cmd = tokio::process::Command::new(&sb_forge);
cmd.current_dir(&project_path);
cmd.args(["run", &target]);
if let Some(e) = env {
cmd.args(["--env", &e]);
}
let start = std::time::Instant::now();
let output = cmd.output().await
.map_err(|e| format!("Fehler beim Ausführen von sb-forge: {}", e))?;
let duration_ms = start.elapsed().as_millis() as u64;
Ok(RunResult {
success: output.status.success(),
exit_code: output.status.code(),
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
duration_ms,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_discover_forge_projects() {
let projects = discover_all_forge_projects();
assert!(!projects.is_empty(), "Should discover at least one Forge.toml");
println!("Discovered {} Forge projects:", projects.len());
for p in &projects {
println!(" - {} (v{}) [root={}] at {}", p.name, p.version, p.is_root, p.path);
}
let loaded = load_forge_project(None);
assert!(loaded.is_ok(), "Auto-discovery should successfully load a Forge project: {:?}", loaded.err());
let details = loaded.unwrap();
assert!(!details.name.is_empty());
}
}