use crate::templating::{Subs, is_binary, substitute, templated_path};
use anyhow::{Context, Result, anyhow, bail};
use include_dir::{Dir, include_dir};
use std::fs;
use std::path::Path;
static TEMPLATES: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/templates");
const GUIDE_BASE: &str = include_str!("../agentic/CLAUDE.md");
const GUIDE_SHARED_UI: &str = include_str!("../agentic/shared-ui.md");
const GUIDE_API: &str = include_str!("../agentic/api.md");
#[derive(Clone, Copy, clap::ValueEnum)]
pub enum AgenticGuide {
Generic,
#[value(name = "shared-ui")]
SharedUi,
Api,
}
fn agentic_guide(flavor: AgenticGuide) -> String {
match flavor {
AgenticGuide::Generic => GUIDE_BASE.to_string(),
AgenticGuide::SharedUi => format!("{GUIDE_BASE}{GUIDE_SHARED_UI}"),
AgenticGuide::Api => format!("{GUIDE_BASE}{GUIDE_API}"),
}
}
const FALLBACK_NDK_VERSION: &str = "30.0.14904198";
pub fn run(raw_name: &str, package: Option<&str>, agentic: Option<AgenticGuide>) -> Result<()> {
let name = sanitize_project_name(raw_name)?;
let display_name = display_name_from(&name);
let package = package
.map(str::to_string)
.unwrap_or_else(|| default_package(&name));
validate_package(&package)?;
let out_dir = std::env::current_dir()
.context("could not read current directory")?
.join(&name);
if out_dir.exists() {
bail!("destination `{}` already exists", out_dir.display());
}
let android_home = std::env::var("ANDROID_HOME").ok();
let ndk_version = detect_ndk_version(android_home.as_deref())
.unwrap_or_else(|| FALLBACK_NDK_VERSION.to_string());
let subs = Subs::from_package(package, display_name.clone(), ndk_version.clone());
let mut written = 0usize;
write_dir(&TEMPLATES, &out_dir, &subs, &mut written)?;
make_gradlew_executable(&out_dir)?;
if let Some(sdk_dir) = android_home.as_deref() {
write_local_properties(&out_dir, sdk_dir)?;
written += 1;
}
if let Some(flavor) = agentic {
fs::write(out_dir.join("CLAUDE.md"), agentic_guide(flavor)).context("writing CLAUDE.md")?;
written += 1;
}
println!(
"Created Mobiler app at {} ({} files)",
out_dir.display(),
written
);
println!();
println!(" Rust core: shared/");
println!(" Android shell: Android/");
println!(" Package: {}", subs.package);
println!();
println!("Next steps:");
println!(" cd {name}");
println!(" cargo test # run Rust core tests");
println!(" cd Android");
println!(" ./gradlew :app:assembleDebug # build APK");
println!(" adb install -r app/build/outputs/apk/debug/app-debug.apk");
if agentic.is_some() {
println!();
println!("Wrote CLAUDE.md — a coding agent (e.g. Claude Code) will use it to build idiomatically.");
}
Ok(())
}
fn write_dir(dir: &Dir<'_>, out_root: &Path, subs: &Subs, written: &mut usize) -> Result<()> {
for entry in dir.entries() {
match entry {
include_dir::DirEntry::Dir(sub) => write_dir(sub, out_root, subs, written)?,
include_dir::DirEntry::File(file) => {
let rel = templated_path(file.path(), subs);
let dst = out_root.join(&rel);
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("creating dir {}", parent.display()))?;
}
if is_binary(file.path()) {
fs::write(&dst, file.contents())
.with_context(|| format!("writing {}", dst.display()))?;
} else {
let raw = std::str::from_utf8(file.contents())
.with_context(|| format!("template {} is not UTF-8", file.path().display()))?;
fs::write(&dst, substitute(raw, subs))
.with_context(|| format!("writing {}", dst.display()))?;
}
*written += 1;
}
}
}
Ok(())
}
fn detect_ndk_version(android_home: Option<&str>) -> Option<String> {
let home = android_home?;
let ndk_dir = Path::new(home).join("ndk");
fs::read_dir(ndk_dir)
.ok()?
.filter_map(Result::ok)
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
.filter_map(|e| e.file_name().into_string().ok())
.max()
}
fn write_local_properties(out_dir: &Path, sdk_dir: &str) -> Result<()> {
let path = out_dir.join("Android/local.properties");
fs::write(&path, format!("sdk.dir={sdk_dir}\n"))
.with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
#[cfg(unix)]
fn make_gradlew_executable(out_dir: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let gradlew = out_dir.join("Android/gradlew");
if gradlew.exists() {
let mut perms = fs::metadata(&gradlew)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&gradlew, perms)?;
}
Ok(())
}
#[cfg(not(unix))]
fn make_gradlew_executable(_out_dir: &Path) -> Result<()> {
Ok(())
}
fn sanitize_project_name(raw: &str) -> Result<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
bail!("project name must not be empty");
}
let valid = trimmed
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
if !valid {
bail!("project name `{trimmed}` must contain only [a-zA-Z0-9_-]; got an invalid character");
}
if !trimmed.chars().next().unwrap().is_ascii_alphabetic() {
bail!("project name must start with a letter");
}
Ok(trimmed.to_string())
}
fn display_name_from(name: &str) -> String {
let mut result = String::new();
let mut capitalize_next = true;
for c in name.chars() {
if c == '-' || c == '_' {
capitalize_next = true;
} else if capitalize_next {
result.extend(c.to_uppercase());
capitalize_next = false;
} else {
result.push(c);
}
}
result
}
fn default_package(name: &str) -> String {
let sanitized: String = name
.to_lowercase()
.chars()
.map(|c| if c == '-' { '_' } else { c })
.collect();
format!("dev.mobiler.{sanitized}")
}
fn validate_package(pkg: &str) -> Result<()> {
if pkg.is_empty() {
bail!("package must not be empty");
}
let parts: Vec<&str> = pkg.split('.').collect();
if parts.len() < 2 {
bail!("package must have at least two dot-separated segments (e.g. `dev.example`)");
}
for part in parts {
if part.is_empty() {
bail!("package `{pkg}` has an empty segment");
}
if !part.chars().next().unwrap().is_ascii_alphabetic() {
bail!("package segment `{part}` must start with a letter");
}
if !part.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err(anyhow!("package segment `{part}` may contain only [a-zA-Z0-9_]"));
}
}
Ok(())
}
#[cfg(test)]
mod test {
use super::{
AgenticGuide, agentic_guide, default_package, display_name_from, sanitize_project_name,
validate_package,
};
use crate::templating::{Subs, is_binary, substitute, templated_path};
use std::path::{Path, PathBuf};
#[test]
fn pascal_case_conversion() {
assert_eq!(display_name_from("todos"), "Todos");
assert_eq!(display_name_from("mobiler-test"), "MobilerTest");
assert_eq!(display_name_from("my_app"), "MyApp");
assert_eq!(display_name_from("Counter"), "Counter");
assert_eq!(display_name_from("foo-bar-baz"), "FooBarBaz");
}
fn subs() -> Subs {
Subs::from_package("dev.mobiler.todos".into(), "Todos".into(), "30.0.14904198".into())
}
#[test]
fn templated_path_strips_tmpl_suffix() {
let s = subs();
assert_eq!(templated_path(Path::new("Cargo.toml.tmpl"), &s), PathBuf::from("Cargo.toml"));
assert_eq!(
templated_path(Path::new("shared/Cargo.toml.tmpl"), &s),
PathBuf::from("shared/Cargo.toml")
);
}
#[test]
fn templated_path_expands_package_path() {
let s = subs();
assert_eq!(
templated_path(Path::new("Android/app/src/main/java/__PACKAGE_PATH__/MainActivity.kt"), &s),
PathBuf::from("Android/app/src/main/java/dev/mobiler/todos/MainActivity.kt")
);
assert_eq!(templated_path(Path::new("shared/src/app.rs"), &s), PathBuf::from("shared/src/app.rs"));
}
#[test]
fn substitute_replaces_every_placeholder_in_specificity_order() {
let s = subs();
let raw = "p={{PACKAGE}} pst={{PACKAGE_SHARED_TYPES}} ps={{PACKAGE_SHARED}} \
pp={{PACKAGE_PATH}} n={{NAME}} ndk={{NDK_VERSION}}";
let out = substitute(raw, &s);
assert_eq!(
out,
"p=dev.mobiler.todos pst=dev.mobiler.todos.shared.types ps=dev.mobiler.todos.shared \
pp=dev/mobiler/todos n=Todos ndk=30.0.14904198"
);
assert!(!out.contains("{{"), "no placeholder should be left behind");
}
#[test]
fn sanitize_project_name_accepts_valid_and_rejects_bad() {
assert_eq!(sanitize_project_name(" my-app ").unwrap(), "my-app");
assert_eq!(sanitize_project_name("Counter").unwrap(), "Counter");
assert!(sanitize_project_name("").is_err());
assert!(sanitize_project_name(" ").is_err());
assert!(sanitize_project_name("1app").is_err());
assert!(sanitize_project_name("my app").is_err());
assert!(sanitize_project_name("my.app").is_err());
}
#[test]
fn validate_package_enforces_segments_and_chars() {
assert!(validate_package("dev.mobiler.todos").is_ok());
assert!(validate_package("dev.example").is_ok());
assert!(validate_package("").is_err());
assert!(validate_package("single").is_err());
assert!(validate_package("dev..todos").is_err());
assert!(validate_package("dev.1bad").is_err());
assert!(validate_package("dev.bad-seg").is_err());
}
#[test]
fn default_package_lowercases_and_underscores_hyphens() {
assert_eq!(default_package("Todos"), "dev.mobiler.todos");
assert_eq!(default_package("my-app"), "dev.mobiler.my_app");
assert_eq!(default_package("Counter"), "dev.mobiler.counter");
}
#[test]
fn is_binary_by_extension_and_by_name() {
assert!(is_binary(Path::new("app/src/main/res/icon.png")));
assert!(is_binary(Path::new("libs/foo.jar")));
assert!(is_binary(Path::new("Android/gradlew.bat")));
assert!(is_binary(Path::new("gradle/wrapper/gradle-wrapper.jar")));
assert!(!is_binary(Path::new("shared/src/app.rs")));
assert!(!is_binary(Path::new("Android/app/build.gradle.kts")));
}
#[test]
fn agentic_guides_compose_base_plus_flavor() {
let base = agentic_guide(AgenticGuide::Generic);
assert!(base.contains("MobilerApp"), "base explains the core trait");
assert!(base.contains("widget vocabulary"), "base lists the UI builder vocabulary");
assert!(base.contains("cx."), "base covers capabilities");
assert!(base.len() > 1500);
assert!(!base.contains("mobiler_web"), "base must not assume a web target");
assert!(!base.to_lowercase().contains("trunk"), "base must not mention the web toolchain");
let shared = agentic_guide(AgenticGuide::SharedUi);
assert!(shared.starts_with(&base), "flavor guides begin with the base primer");
assert!(
shared.contains("same UI on mobile and web") && shared.contains("mobiler_web"),
"shared-ui adds the web target"
);
let api = agentic_guide(AgenticGuide::Api);
assert!(api.starts_with(&base));
assert!(api.contains("reusable core + JSON API") && api.contains("SQLx"), "api appendix present");
}
}