use anyhow::{bail, Result};
use console::style;
use dialoguer::{Confirm, Input, MultiSelect, Select};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::config;
use crate::config::schema::{DependencyValue, YmConfig};
use crate::jdk_manager;
pub fn execute(
name: Option<String>,
interactive: bool,
template: Option<String>,
_yes: bool,
) -> Result<()> {
let dir = resolve_dir(name.as_deref())?;
let config_path = dir.join(config::CONFIG_FILE);
if config_path.exists() {
bail!("ym.json already exists in {}", dir.display());
}
if let Some(tpl) = template {
return execute_from_template(&dir, &tpl);
}
if interactive {
execute_interactive(&dir)
} else {
execute_defaults(&dir)
}
}
fn resolve_dir(name: Option<&str>) -> Result<PathBuf> {
let cwd = std::env::current_dir()?;
match name {
None => Ok(cwd),
Some(project_name) => Ok(cwd.join(project_name)),
}
}
fn execute_defaults(dir: &Path) -> Result<()> {
let dir_name = dir_name(dir);
let pkg = default_package(&dir_name);
let mut env = BTreeMap::new();
env.insert("APP_PORT".to_string(), "8080".to_string());
let config = YmConfig {
name: dir_name.clone(),
group_id: "com.example".to_string(),
version: Some("0.1.0".to_string()),
target: Some("21".to_string()),
package: Some(pkg.clone()),
main: Some(format!("{}.Application", pkg)),
dependencies: Some(BTreeMap::new()),
scripts: Some(default_scripts()),
env: Some(env),
..Default::default()
};
write_project(dir, &config)?;
crate::scripts::run_script(&config, "postinit", dir)?;
let main_class = config.main.as_deref().unwrap_or("Application");
let main_path = main_class.replace('.', "/");
println!();
println!(" {} Created ym.json", style("✓").green());
println!(" {} Created src/main/java/{}.java", style("✓").green(), main_path);
println!();
println!(" Done! Next steps:");
if dir != std::env::current_dir().unwrap_or_default() {
println!(" cd {}", dir_name);
}
println!(" ym dev");
println!();
Ok(())
}
fn execute_interactive(dir: &Path) -> Result<()> {
let default_name = dir_name(dir);
let pkg_input: String = Input::new()
.with_prompt("package name")
.default(default_package(&default_name))
.interact_text()?;
println!();
let term = console::Term::stderr();
let _ = term.write_line(&format!(" {} scanning JDKs...", style("➜").green()));
let jdks = jdk_manager::scan_jdks();
let _ = term.clear_last_lines(1);
let java_version = select_java_version(&jdks)?;
let template_items = ["app", "lib", "spring-boot"];
let template_idx = Select::new()
.with_prompt("template")
.items(&template_items)
.default(0)
.interact()?;
let template = template_items[template_idx];
let dev_jdk_path = select_jdk("Select DEV JDK (for hot reload)", &jdks, true)?;
let dir_name = dir_name(dir);
let mut config = YmConfig {
name: dir_name.clone(),
group_id: "com.example".to_string(),
version: Some("0.1.0".to_string()),
target: Some(java_version),
package: Some(pkg_input.clone()),
dependencies: Some(BTreeMap::new()),
scripts: Some(default_scripts()),
..Default::default()
};
let env = config.env.get_or_insert_with(BTreeMap::new);
env.insert("APP_PORT".to_string(), "8080".to_string());
if let Some(ref path) = dev_jdk_path {
env.insert("DEV_JAVA_HOME".to_string(), shorten_home(path));
if let Some(ref mut scripts) = config.scripts {
use config::schema::ScriptValue;
scripts.insert("dev".to_string(), ScriptValue::Simple("JAVA_HOME=$DEV_JAVA_HOME ymc dev".to_string()));
}
}
match template {
"spring-boot" => {
let mut deps = BTreeMap::new();
deps.insert(
"org.springframework.boot:spring-boot-starter-web".to_string(),
DependencyValue::Simple("3.4.0".to_string()),
);
config.dependencies = Some(deps);
config.main = Some(format!("{}.Application", pkg_input));
}
"lib" => {
let mut deps = BTreeMap::new();
deps.insert(
"org.junit.jupiter:junit-jupiter".to_string(),
DependencyValue::Detailed(crate::config::schema::DependencySpec {
version: Some("5.11.0".to_string()),
scope: Some("test".to_string()),
..Default::default()
}),
);
deps.insert(
"org.junit.platform:junit-platform-console-standalone".to_string(),
DependencyValue::Detailed(crate::config::schema::DependencySpec {
version: Some("1.11.0".to_string()),
scope: Some("test".to_string()),
..Default::default()
}),
);
config.dependencies = Some(deps);
config.main = None; }
_ => {
config.main = Some(format!("{}.Application", pkg_input));
}
}
let extra_deps = select_optional_deps(template)?;
if !extra_deps.is_empty() {
let deps = config.dependencies.get_or_insert_with(BTreeMap::new);
for (coord, value) in extra_deps {
deps.insert(coord, value);
}
}
write_project_for_template(dir, &config, template)?;
crate::scripts::run_script(&config, "postinit", dir)?;
println!();
println!(
" {} Created {} project",
style("✓").green(),
style(template).cyan()
);
println!();
println!(" Done! Next steps:");
if dir != std::env::current_dir().unwrap_or_default() {
println!(" cd {}", dir_name);
}
println!(" ym dev");
println!();
Ok(())
}
fn select_java_version(_jdks: &[jdk_manager::DetectedJdk]) -> Result<String> {
let version_items = ["25 (latest)", "21 (LTS)", "17 (LTS)"];
let version_idx = Select::new()
.with_prompt("Java version")
.items(&version_items)
.default(0)
.interact()?;
Ok(match version_idx {
0 => "25".to_string(),
1 => "21".to_string(),
2 => "17".to_string(),
_ => "21".to_string(),
})
}
fn select_jdk(label: &str, jdks: &[jdk_manager::DetectedJdk], prefer_dcevm: bool) -> Result<Option<PathBuf>> {
println!();
println!(" {}", style(label).bold());
if jdks.is_empty() {
println!(" No JDKs found locally.");
let download = Confirm::new()
.with_prompt(" Download a JDK?")
.default(true)
.interact()?;
if download {
return download_jdk_interactive();
}
return Ok(None);
}
let sorted_jdks: Vec<&jdk_manager::DetectedJdk> = if prefer_dcevm {
let mut dcevm: Vec<_> = jdks.iter().filter(|j| j.has_dcevm).collect();
let mut rest: Vec<_> = jdks.iter().filter(|j| !j.has_dcevm).collect();
dcevm.append(&mut rest);
dcevm
} else {
jdks.iter().collect()
};
let mut items: Vec<String> = Vec::new();
items.push(format!("{}", style("Skip").dim()));
for jdk in &sorted_jdks {
let mut label = format!(
"{:<20} {} ({})",
jdk.display_name(),
style(jdk.path.display()).dim(),
jdk.source,
);
if prefer_dcevm && jdk.has_dcevm {
label = format!("{} {}", label, style("★ hot reload").yellow());
}
items.push(label);
}
items.push(format!("{}", style("Download other...").cyan()));
let default = if prefer_dcevm && sorted_jdks.first().map(|j| j.has_dcevm).unwrap_or(false) {
1 } else {
0 };
let selection = Select::new()
.items(&items)
.default(default)
.interact()?;
if selection == 0 {
return Ok(None); }
if selection == items.len() - 1 {
return download_jdk_interactive();
}
let selected = sorted_jdks[selection - 1];
println!(
" {} {}",
style("✓").green(),
selected.display_name()
);
Ok(Some(selected.path.clone()))
}
fn download_jdk_interactive() -> Result<Option<PathBuf>> {
let mut vendor_labels: Vec<String> = vec![
format!("{} {}", "JetBrains Runtime (JBR)", style("★ recommended").yellow()),
];
for (label, _) in jdk_manager::DOWNLOAD_VENDORS.iter() {
if !label.to_lowercase().contains("jetbrains") && !label.to_lowercase().contains("jbr") {
vendor_labels.push(label.to_string());
}
}
vendor_labels.push(format!("{}", style("Skip").dim()));
println!();
let vendor_idx = Select::new()
.with_prompt(" Provider")
.items(&vendor_labels)
.default(0)
.interact()?;
if vendor_idx == vendor_labels.len() - 1 {
return Ok(None);
}
let version_labels = ["25 (latest)", "21 (LTS)", "17 (LTS)"];
let version_idx = Select::new()
.with_prompt(" Version")
.items(&version_labels)
.default(0)
.interact()?;
let version_key = match version_idx {
0 => "25",
1 => "21",
2 => "17",
_ => "25",
};
let vendor_key = if vendor_idx == 0 {
"jbr"
} else {
jdk_manager::DOWNLOAD_VENDORS
.iter()
.find(|(label, _)| vendor_labels.get(vendor_idx).map(|l| l == *label).unwrap_or(false))
.map(|(_, key)| *key)
.unwrap_or("temurin")
};
let path = jdk_manager::download_jdk(vendor_key, version_key)?;
Ok(Some(path))
}
fn shorten_home(path: &Path) -> String {
let home = crate::home_dir_string();
let s = path.display().to_string();
if let Some(rest) = s.strip_prefix(&home) {
return format!("~{}", rest);
}
s
}
fn default_scripts() -> BTreeMap<String, config::schema::ScriptValue> {
use config::schema::ScriptValue;
let mut s = BTreeMap::new();
s.insert("build".into(), ScriptValue::Simple("ymc build".into()));
s.insert("build:clean".into(), ScriptValue::Simple("ymc build --clean".into()));
s.insert("dev".into(), ScriptValue::Simple("ymc dev".into()));
s.insert("dev:debug".into(), ScriptValue::Simple("ymc dev --debug".into()));
s.insert("test".into(), ScriptValue::Simple("ymc test".into()));
s.insert("test:watch".into(), ScriptValue::Simple("ymc test --watch".into()));
s.insert("test:coverage".into(), ScriptValue::Simple("ymc test --coverage".into()));
s.insert("clean".into(), ScriptValue::Simple("ym cache clean -y".into()));
s.insert("idea".into(), ScriptValue::Simple("ymc idea --sources".into()));
s.insert("native".into(), ScriptValue::Simple("ymc native".into()));
s.insert("native:docker".into(), ScriptValue::Simple("ymc native --docker".into()));
s.insert("native:install".into(), ScriptValue::Simple("ymc native --install".into()));
s.insert("docker:jar".into(), ScriptValue::Simple("ymc build && docker build -f docker/jar/Dockerfile --build-arg APP_NAME=${project.name} --build-arg APP_VERSION=${project.version} --build-arg APP_PORT=$APP_PORT -t ${project.name}:${project.version} .".into()));
s.insert("docker:jar:publish".into(), ScriptValue::Simple("ymc build && docker build -f docker/jar/Dockerfile --build-arg APP_NAME=${project.name} --build-arg APP_VERSION=${project.version} --build-arg APP_PORT=$APP_PORT -t ${project.name}:${project.version} -t ${project.name}:latest . && docker push ${project.name}:${project.version} && docker push ${project.name}:latest".into()));
s.insert("docker:native".into(), ScriptValue::Simple("ymc native && docker build -f docker/native/Dockerfile --build-arg APP_NAME=${project.name} --build-arg APP_VERSION=${project.version} --build-arg APP_PORT=$APP_PORT -t ${project.name}:${project.version} .".into()));
s.insert("docker:native:publish".into(), ScriptValue::Simple("ymc native && docker build -f docker/native/Dockerfile --build-arg APP_NAME=${project.name} --build-arg APP_VERSION=${project.version} --build-arg APP_PORT=$APP_PORT -t ${project.name}:${project.version} -t ${project.name}:latest . && docker push ${project.name}:${project.version} && docker push ${project.name}:latest".into()));
s
}
fn execute_from_template(dir: &Path, template: &str) -> Result<()> {
if is_custom_template(template) {
return execute_from_custom_template(dir, template);
}
let dir_name = dir_name(dir);
let pkg = default_package(&dir_name);
let mut env = BTreeMap::new();
env.insert("APP_PORT".to_string(), "8080".to_string());
let mut config = YmConfig {
name: dir_name.clone(),
group_id: "com.example".to_string(),
version: Some("0.1.0".to_string()),
target: Some("21".to_string()),
package: Some(pkg.clone()),
dependencies: Some(BTreeMap::new()),
scripts: Some(default_scripts()),
env: Some(env),
..Default::default()
};
match template {
"spring-boot" | "spring" => {
let mut deps = BTreeMap::new();
deps.insert(
"org.springframework.boot:spring-boot-starter-web".to_string(),
DependencyValue::Simple("3.4.0".to_string()),
);
config.dependencies = Some(deps);
config.main = Some(format!("{}.Application", pkg));
}
"lib" | "library" => {
let mut deps = BTreeMap::new();
deps.insert(
"org.junit.jupiter:junit-jupiter".to_string(),
DependencyValue::Detailed(crate::config::schema::DependencySpec {
version: Some("5.11.0".to_string()),
scope: Some("test".to_string()),
..Default::default()
}),
);
deps.insert(
"org.junit.platform:junit-platform-console-standalone".to_string(),
DependencyValue::Detailed(crate::config::schema::DependencySpec {
version: Some("1.11.0".to_string()),
scope: Some("test".to_string()),
..Default::default()
}),
);
config.dependencies = Some(deps);
config.main = None;
}
_ => {
config.main = Some(format!("{}.Application", pkg));
}
}
write_project_for_template(dir, &config, template)?;
crate::scripts::run_script(&config, "postinit", dir)?;
println!();
println!(
" {} Created {} project from '{}' template",
style("✓").green(),
style(&dir_name).bold(),
style(template).cyan()
);
println!();
println!(" Done! Next steps:");
if dir != std::env::current_dir().unwrap_or_default() {
println!(" cd {}", dir_name);
}
println!(" ym dev");
println!();
Ok(())
}
fn is_custom_template(template: &str) -> bool {
template.starts_with("https://")
|| template.starts_with("http://")
|| template.starts_with("git@")
|| template.starts_with("./")
|| template.starts_with("../")
|| template.starts_with('/')
}
fn execute_from_custom_template(dir: &Path, template: &str) -> Result<()> {
std::fs::create_dir_all(dir)?;
if template.starts_with("https://") || template.starts_with("http://") || template.starts_with("git@") {
println!(
" {} cloning template from {}",
style("➜").green(),
style(template).dim()
);
let tmp = tempfile::tempdir()?;
let status = std::process::Command::new("git")
.args(["clone", "--depth", "1", template, tmp.path().to_str().unwrap()])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.status()?;
if !status.success() {
bail!("Failed to clone template from {}", template);
}
copy_dir_contents(tmp.path(), dir)?;
} else {
let src = Path::new(template);
if !src.is_dir() {
bail!("Template directory not found: {}", template);
}
println!(
" {} copying template from {}",
style("➜").green(),
style(template).dim()
);
copy_dir_contents(src, dir)?;
}
let config_path = dir.join(config::CONFIG_FILE);
if config_path.exists() {
if let Ok(cfg) = config::load_config(&config_path) {
crate::scripts::run_script(&cfg, "postinit", dir)?;
}
}
let dn = dir_name(dir);
println!();
println!(
" {} Created project from custom template",
style("✓").green(),
);
println!();
println!(" Done! Next steps:");
if dir != std::env::current_dir().unwrap_or_default() {
println!(" cd {}", dn);
}
println!(" ym dev");
println!();
Ok(())
}
fn copy_dir_contents(src: &Path, dst: &Path) -> Result<()> {
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str == ".git" {
continue;
}
let src_path = entry.path();
let dst_path = dst.join(&name);
if src_path.is_dir() {
std::fs::create_dir_all(&dst_path)?;
copy_dir_contents(&src_path, &dst_path)?;
} else {
std::fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}
fn write_project(dir: &Path, config: &YmConfig) -> Result<()> {
std::fs::create_dir_all(dir)?;
let config_path = dir.join(config::CONFIG_FILE);
config::save_config(&config_path, config)?;
let src_dir = dir.join("src").join("main").join("java");
std::fs::create_dir_all(&src_dir)?;
create_sample_main(&src_dir, config)?;
let resources_dir = dir.join("src").join("main").join("resources");
std::fs::create_dir_all(&resources_dir)?;
let test_dir = dir.join("src").join("test").join("java");
std::fs::create_dir_all(&test_dir)?;
let target = config.target.as_deref().unwrap_or("21");
write_dockerfiles(dir, target)?;
write_gitignore(dir)?;
Ok(())
}
fn write_project_for_template(dir: &Path, config: &YmConfig, template: &str) -> Result<()> {
std::fs::create_dir_all(dir)?;
let config_path = dir.join(config::CONFIG_FILE);
config::save_config(&config_path, config)?;
let src_dir = dir.join("src").join("main").join("java");
std::fs::create_dir_all(&src_dir)?;
let resources_dir = dir.join("src").join("main").join("resources");
std::fs::create_dir_all(&resources_dir)?;
let test_java_dir = dir.join("src").join("test").join("java");
std::fs::create_dir_all(&test_java_dir)?;
let pkg = config.package.as_deref().unwrap_or("com.example");
let pkg_dir = src_dir.join(pkg.replace('.', "/"));
std::fs::create_dir_all(&pkg_dir)?;
match template {
"spring-boot" | "spring" => {
let main_content = format!(
r#"package {};
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {{
public static void main(String[] args) {{
SpringApplication.run(Application.class, args);
}}
}}
"#,
pkg
);
std::fs::write(pkg_dir.join("Application.java"), main_content)?;
std::fs::write(resources_dir.join("application.yml"), "server:\n port: 8080\n")?;
}
"lib" | "library" => {
let class_name = to_class_name(&config.name);
let lib_content = format!(
r#"package {};
public class {} {{
public String greet(String name) {{
return "Hello, " + name + "!";
}}
}}
"#,
pkg, class_name
);
std::fs::write(pkg_dir.join(format!("{}.java", class_name)), lib_content)?;
let test_pkg_dir = test_java_dir.join(pkg.replace('.', "/"));
std::fs::create_dir_all(&test_pkg_dir)?;
let test_content = format!(
r#"package {};
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class {}Test {{
@Test
void testGreet() {{
{} lib = new {}();
assertEquals("Hello, World!", lib.greet("World"));
}}
}}
"#,
pkg, class_name, class_name, class_name
);
std::fs::write(
test_pkg_dir.join(format!("{}Test.java", class_name)),
test_content,
)?;
}
_ => {
create_sample_main(&src_dir, config)?;
}
}
let target = config.target.as_deref().unwrap_or("21");
write_dockerfiles(dir, target)?;
write_gitignore(dir)?;
Ok(())
}
fn write_dockerfiles(dir: &Path, target: &str) -> Result<()> {
let jar_dir = dir.join("docker").join("jar");
std::fs::create_dir_all(&jar_dir)?;
std::fs::write(
jar_dir.join("Dockerfile"),
format!(
r#"FROM eclipse-temurin:{}-jre-alpine
ARG APP_NAME
ARG APP_VERSION
ARG APP_PORT=8080
COPY out/release/${{APP_NAME}}-${{APP_VERSION}}.jar /app.jar
EXPOSE ${{APP_PORT}}
ENTRYPOINT ["java", "-jar", "/app.jar"]
"#,
target
),
)?;
let native_dir = dir.join("docker").join("native");
std::fs::create_dir_all(&native_dir)?;
std::fs::write(
native_dir.join("Dockerfile"),
r#"FROM alpine:3
ARG APP_NAME
ARG APP_VERSION
ARG APP_PORT=8080
COPY out/release/${APP_NAME} /app
EXPOSE ${APP_PORT}
ENTRYPOINT ["/app"]
"#,
)?;
Ok(())
}
fn write_gitignore(dir: &Path) -> Result<()> {
let gitignore_path = dir.join(".gitignore");
if !gitignore_path.exists() {
std::fs::write(&gitignore_path, "out/\n.ym/\n.ym-sources.txt\n*.class\n")?;
}
Ok(())
}
pub fn sanitize_package_name(name: &str) -> String {
name.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect::<String>()
.to_lowercase()
}
pub fn default_package(name: &str) -> String {
format!("com.example.{}", sanitize_package_name(name))
}
fn dir_name(dir: &Path) -> String {
dir.file_name()
.and_then(|n| n.to_str())
.unwrap_or("my-app")
.to_string()
}
fn to_class_name(name: &str) -> String {
name.split(['-', '_'])
.map(|part| {
let mut c = part.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
}
})
.collect()
}
fn create_sample_main(src_dir: &Path, config: &YmConfig) -> Result<()> {
let main_class = config.main.as_deref().unwrap_or("Application");
let (pkg, class_name) = if let Some(idx) = main_class.rfind('.') {
(Some(&main_class[..idx]), &main_class[idx + 1..])
} else {
(config.package.as_deref(), main_class)
};
let pkg_dir = if let Some(p) = pkg {
src_dir.join(p.replace('.', "/"))
} else {
src_dir.to_path_buf()
};
std::fs::create_dir_all(&pkg_dir)?;
let main_file = pkg_dir.join(format!("{}.java", class_name));
if main_file.exists() {
return Ok(());
}
let package_decl = if let Some(p) = pkg {
format!("package {};\n\n", p)
} else {
String::new()
};
let content = format!(
r#"{}public class {} {{
public static void main(String[] args) {{
System.out.println("Hello, World!");
}}
}}
"#,
package_decl, class_name
);
std::fs::write(&main_file, content)?;
Ok(())
}
const COMMON_DEPS: &[(&str, &str, &str, &str)] = &[
("Jackson (JSON)", "com.fasterxml.jackson.core:jackson-databind", "2.18.2", "compile"),
("Lombok", "org.projectlombok:lombok", "1.18.36", "provided"),
("SLF4J + Logback", "ch.qos.logback:logback-classic", "1.5.16", "compile"),
("Google Guava", "com.google.guava:guava", "33.4.0-jre", "compile"),
("Apache Commons Lang", "org.apache.commons:commons-lang3", "3.17.0", "compile"),
("JUnit Jupiter (test)", "org.junit.jupiter:junit-jupiter", "5.11.4", "test"),
("Mockito (test)", "org.mockito:mockito-core", "5.14.2", "test"),
("AssertJ (test)", "org.assertj:assertj-core", "3.27.3", "test"),
];
fn select_optional_deps(template: &str) -> Result<BTreeMap<String, DependencyValue>> {
if template == "spring-boot" || template == "lib" {
return Ok(BTreeMap::new());
}
println!();
let labels: Vec<&str> = COMMON_DEPS.iter().map(|(l, _, _, _)| *l).collect();
let selections = MultiSelect::new()
.with_prompt("Add dependencies (space to select, enter to confirm)")
.items(&labels)
.interact()?;
let mut deps = BTreeMap::new();
for idx in selections {
let (_, coord, version, scope) = COMMON_DEPS[idx];
let value = if scope == "compile" {
DependencyValue::Simple(version.to_string())
} else {
DependencyValue::Detailed(crate::config::schema::DependencySpec {
version: Some(version.to_string()),
scope: Some(scope.to_string()),
..Default::default()
})
};
deps.insert(coord.to_string(), value);
}
Ok(deps)
}