use clap::Args;
use nativ_config::NativConfig;
use std::path::Path;
#[derive(Args)]
pub struct InitArgs {
pub name: Option<String>,
}
fn app_decl_name(project_name: &str) -> String {
let pascal: String = project_name
.split(['-', '_'])
.filter(|s| !s.is_empty())
.map(|s| {
let mut chars = s.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
})
.collect();
if pascal
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic())
{
pascal
} else {
format!("App{pascal}")
}
}
pub fn run(args: InitArgs, verbose: bool) -> Result<(), Box<dyn std::error::Error>> {
let project_name = args.name.unwrap_or_else(|| {
std::env::current_dir()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
.unwrap_or_else(|| "my-app".to_string())
});
if project_name.contains(' ') {
return Err("Project name cannot contain spaces. Example: 'my-app' or 'myapp'".into());
}
if !project_name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Err(
"Project name must only contain letters, numbers, hyphens (-) and underscores (_)"
.into(),
);
}
let project_dir = Path::new(&project_name);
if project_dir.exists() {
return Err(format!(
"Directory already exists: '{}'\n Hint: choose a different name or remove the existing directory",
project_name
)
.into());
}
if verbose {
println!("Creating project: {project_name}");
}
let dirs = [
"",
"assets",
"i18n",
"src",
"src/screens",
"src/components",
"src/models",
];
for dir in &dirs {
let path = project_dir.join(dir);
std::fs::create_dir_all(&path)?;
if verbose {
println!(" Dir: {}", path.display());
}
}
let config_content = NativConfig::default_toml(&project_name);
std::fs::write(project_dir.join("nativ.toml"), config_content)?;
std::fs::write(project_dir.join(".gitignore"), "build/\n.DS_Store\n")?;
let decl_name = app_decl_name(&project_name);
std::fs::write(
project_dir.join("src/app.nativ"),
format!(
r#"app {decl_name}:
name: "{project_name}"
start: Home
"#
),
)?;
std::fs::write(
project_dir.join("src/models/Item.nativ"),
r#"model Item:
title: text
done: boolean
"#,
)?;
std::fs::write(
project_dir.join("src/screens/Home.nativ"),
format!(
r#"screen Home:
state items: list of Item = [Item(title: "Welcome to {project_name}!", done: false), Item(title: "Tap a row to navigate", done: false), Item(title: "Long-press for haptics", done: false)]
state count = 0
scroll:
column spacing: 16:
text "{project_name}", big, bold
text "Tap the button to add items. Tap a row to navigate.", small, gray
button "Add Item ({{count}})":
count += 1
haptic impact
if count > 5:
text "That's a lot of items!", green, bold
else:
text "Keep tapping!", small, gray
divider
each item in items:
card:
column spacing: 4:
text item.title, bold
text "Tap to view details", small, gray
on tap:
go to Detail(item)
on long press:
haptic notification
"#
),
)?;
std::fs::write(
project_dir.join("src/screens/Detail.nativ"),
r#"screen Detail(item):
state revealed = false
column spacing: 16:
text item.title, big, bold
if item.done:
text "This item is done.", green
else:
text "This item is not done yet.", gray
if revealed:
text "You revealed the detail!", bold
else:
button "Reveal":
revealed = true
haptic impact
button "Back":
go back
"#,
)?;
std::fs::write(
project_dir.join("i18n/en.json"),
"{\n \"welcome\": \"Welcome\",\n \"get_started\": \"Get Started\"\n}\n",
)?;
println!("Project created: {project_name}/");
println!();
println!(" cd {project_name}");
println!(" nativ build");
println!();
println!("to get started.");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn app_decl_name_pascal_cases_project_names() {
assert_eq!(app_decl_name("demo"), "Demo");
assert_eq!(app_decl_name("my-app"), "MyApp");
assert_eq!(app_decl_name("my_cool_app2"), "MyCoolApp2");
assert_eq!(app_decl_name("TodoApp"), "TodoApp");
assert_eq!(app_decl_name("2cool"), "App2cool");
}
#[test]
fn rejects_name_with_spaces() {
let args = InitArgs {
name: Some("bad name".to_string()),
};
let err = run(args, false).unwrap_err();
assert!(err.to_string().contains("spaces"), "{err}");
}
#[test]
fn rejects_name_with_special_characters() {
for bad in ["bad!name", "bad/name", "bad.name", "bad☃name"] {
let args = InitArgs {
name: Some(bad.to_string()),
};
let err = run(args, false).unwrap_err();
assert!(
err.to_string().contains("letters, numbers"),
"unexpected error for {bad}: {err}"
);
}
}
}