use anyhow::{anyhow, Context, Result};
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::services::config::JuglansConfig;
const BASE_IMAGE: &str = "juglansai/juglans:latest";
const CONTAINER_PREFIX: &str = "juglans";
const DEFAULT_PORT: u16 = 8080;
const INCLUDE_EXTS: &[&str] = &[
"jg", "jgx", "jgprompt", "json", "yaml", "yml", "toml", "csv", "txt", "py", "md",
];
const EXCLUDE_DIRS: &[&str] = &[".git", "target", "node_modules", "__pycache__"];
pub struct DeployConfig {
pub tag: Option<String>,
pub port: Option<u16>,
pub build_only: bool,
pub push: bool,
pub stop: bool,
pub status: bool,
pub env_vars: Vec<String>,
pub path: Option<PathBuf>,
}
pub fn handle_deploy(config: DeployConfig) -> Result<()> {
let DeployConfig {
tag,
port,
build_only,
push,
stop,
status,
env_vars,
path,
} = config;
check_docker_available()?;
let project_root = resolve_project_root(path.as_deref())?;
let project_name = derive_project_name(&project_root)?;
let container_name = format!("{}-{}", CONTAINER_PREFIX, project_name);
let port = port.unwrap_or(DEFAULT_PORT);
let image_tag = tag.unwrap_or_else(|| format!("{}:latest", container_name));
if stop {
return stop_container(&container_name);
}
if status {
return show_container_status(&container_name);
}
validate_project(&project_root)?;
let build_dir = prepare_build_context(&project_root, &project_name, port)?;
build_image(&build_dir, &image_tag)?;
let _ = fs::remove_dir_all(&build_dir);
if push {
push_image(&image_tag)?;
}
if build_only {
println!("Image built: {}", image_tag);
return Ok(());
}
let mut all_env = load_config_env();
for item in &env_vars {
if let Some((k, v)) = item.split_once('=') {
all_env.insert(k.to_string(), v.to_string());
} else {
return Err(anyhow!("Invalid env format '{}', expected KEY=VALUE", item));
}
}
run_container(&container_name, &image_tag, port, &all_env)?;
Ok(())
}
fn check_docker_available() -> Result<()> {
let output = Command::new("docker")
.arg("version")
.arg("--format")
.arg("{{.Server.Version}}")
.output()
.context("Failed to run 'docker'. Is Docker installed and in PATH?")?;
if !output.status.success() {
return Err(anyhow!(
"Docker daemon is not running. Start Docker and try again."
));
}
Ok(())
}
fn resolve_project_root(path: Option<&Path>) -> Result<PathBuf> {
let start = match path {
Some(p) => {
fs::canonicalize(p).with_context(|| format!("Path not found: {}", p.display()))?
}
None => env::current_dir().context("Cannot determine current directory")?,
};
let mut dir = start.as_path();
loop {
if dir.join("juglans.toml").exists() {
return Ok(dir.to_path_buf());
}
match dir.parent() {
Some(parent) => dir = parent,
None => {
return Err(anyhow!(
"juglans.toml not found (searched from {})",
start.display()
))
}
}
}
}
fn derive_project_name(root: &Path) -> Result<String> {
let manifest_path = root.join("jgpackage.toml");
if manifest_path.exists() {
let content = fs::read_to_string(&manifest_path)?;
if let Ok(table) = content.parse::<toml::Table>() {
if let Some(pkg) = table.get("package").and_then(|v| v.as_table()) {
if let Some(name) = pkg.get("name").and_then(|v| v.as_str()) {
return Ok(sanitize_name(name));
}
}
}
}
root.file_name()
.and_then(|n| n.to_str())
.map(sanitize_name)
.ok_or_else(|| anyhow!("Cannot derive project name from path"))
}
fn sanitize_name(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' {
c
} else {
'-'
}
})
.collect::<String>()
.to_lowercase()
}
fn validate_project(root: &Path) -> Result<()> {
let has_jg = fs::read_dir(root)?.filter_map(|e| e.ok()).any(|e| {
e.path()
.extension()
.is_some_and(|ext| ext == "jg" || ext == "jgx" || ext == "jgprompt")
});
if !has_jg {
return Err(anyhow!("No .jg/.jgx files found in {}", root.display()));
}
Ok(())
}
fn generate_dockerfile(port: u16) -> String {
format!(
r#"FROM {base}
COPY workspace/ /workspace/
WORKDIR /workspace
EXPOSE {port}
CMD ["juglans", "web", "--host", "0.0.0.0", "--port", "{port}"]
"#,
base = BASE_IMAGE,
port = port
)
}
fn prepare_build_context(project_root: &Path, project_name: &str, port: u16) -> Result<PathBuf> {
let build_dir = env::temp_dir().join(format!("juglans-deploy-{}", project_name));
if build_dir.exists() {
fs::remove_dir_all(&build_dir)?;
}
let workspace_dir = build_dir.join("workspace");
fs::create_dir_all(&workspace_dir)?;
fs::write(build_dir.join("Dockerfile"), generate_dockerfile(port))?;
copy_project_files(project_root, &workspace_dir)?;
Ok(build_dir)
}
fn copy_project_files(src: &Path, dst: &Path) -> Result<()> {
for entry in fs::read_dir(src)? {
let entry = entry?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with('.') {
continue;
}
if EXCLUDE_DIRS.contains(&name_str.as_ref()) {
continue;
}
let src_path = entry.path();
let dst_path = dst.join(&name);
if src_path.is_dir() {
fs::create_dir_all(&dst_path)?;
copy_project_files(&src_path, &dst_path)?;
} else if should_include_file(&src_path) {
fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}
fn should_include_file(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| INCLUDE_EXTS.contains(&ext))
}
fn build_image(build_dir: &Path, tag: &str) -> Result<()> {
println!("Building image {} ...", tag);
let status = Command::new("docker")
.args(["build", "-t", tag, "."])
.current_dir(build_dir)
.status()
.context("Failed to run docker build")?;
if !status.success() {
return Err(anyhow!("docker build failed"));
}
println!("Image built: {}", tag);
Ok(())
}
fn run_container(
name: &str,
image: &str,
port: u16,
env_vars: &HashMap<String, String>,
) -> Result<()> {
let _ = Command::new("docker").args(["rm", "-f", name]).output();
println!("Starting container {} ...", name);
let mut args = vec![
"run".to_string(),
"-d".to_string(),
"--name".to_string(),
name.to_string(),
"-p".to_string(),
format!("{}:{}", port, port),
"--restart".to_string(),
"unless-stopped".to_string(),
];
for (k, v) in env_vars {
args.push("-e".to_string());
args.push(format!("{}={}", k, v));
}
args.push(image.to_string());
let output = Command::new("docker")
.args(&args)
.output()
.context("Failed to run docker run")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("docker run failed: {}", stderr.trim()));
}
let container_id = String::from_utf8_lossy(&output.stdout)
.trim()
.chars()
.take(12)
.collect::<String>();
println!("Container started: {} ({})", name, container_id);
println!(" http://localhost:{}", port);
println!(" Logs: docker logs -f {}", name);
Ok(())
}
fn push_image(tag: &str) -> Result<()> {
println!("Pushing {} ...", tag);
let status = Command::new("docker")
.args(["push", tag])
.status()
.context("Failed to run docker push")?;
if !status.success() {
return Err(anyhow!("docker push failed"));
}
println!("Pushed: {}", tag);
Ok(())
}
fn show_container_status(name: &str) -> Result<()> {
let output = Command::new("docker")
.args([
"inspect",
"--format",
"Status: {{.State.Status}}\nStarted: {{.State.StartedAt}}\nPorts: {{range $p, $conf := .NetworkSettings.Ports}}{{$p}} -> {{(index $conf 0).HostPort}} {{end}}",
name,
])
.output()
.context("Failed to inspect container")?;
if !output.status.success() {
println!("Container '{}' not found.", name);
return Ok(());
}
println!("Container: {}", name);
println!("{}", String::from_utf8_lossy(&output.stdout).trim());
Ok(())
}
fn stop_container(name: &str) -> Result<()> {
println!("Stopping {} ...", name);
let output = Command::new("docker")
.args(["rm", "-f", name])
.output()
.context("Failed to stop container")?;
if output.status.success() {
println!("Stopped and removed: {}", name);
} else {
println!("Container '{}' not found or already stopped.", name);
}
Ok(())
}
fn load_config_env() -> HashMap<String, String> {
JuglansConfig::load().map(|c| c.env).unwrap_or_default()
}