use std::path::PathBuf;
use clap::{Args as ClapArgs, ValueEnum};
use color_eyre::eyre::{Result, bail, eyre};
use dialoguer::{Input, Select, theme::ColorfulTheme};
use heck::ToSnakeCase;
use crate::shell::Shell;
use crate::{header, line, success};
use waterui_cli::framework::FrameworkChannel;
use waterui_cli::project::{CreateOptions, PackageType, Project, WebScaffold};
use waterui_cli::project_types::BundleIdentifier;
use waterui_cli::web::{
self, ExistingFrontendMode, InitAction, InitAnswers, PackageManager, WebSource, plan_init,
};
#[derive(ClapArgs, Debug)]
pub struct Args {
name: Option<String>,
#[arg(long)]
bundle_id: Option<String>,
#[arg(long)]
waterui_path: Option<PathBuf>,
#[arg(long, conflicts_with_all = ["waterui_path", "framework_manifest"])]
channel: Option<FrameworkChannel>,
#[arg(long, conflicts_with_all = ["waterui_path", "channel"])]
framework_manifest: Option<PathBuf>,
#[arg(long)]
web: Option<String>,
#[arg(long, value_enum, requires = "web")]
web_mode: Option<WebMode>,
#[arg(long, value_enum)]
package_manager: Option<PackageManager>,
#[arg(long)]
vite_template: Option<String>,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum WebMode {
Copy,
Reference,
}
impl From<WebMode> for ExistingFrontendMode {
fn from(mode: WebMode) -> Self {
match mode {
WebMode::Copy => Self::Copy,
WebMode::Reference => Self::Reference,
}
}
}
pub async fn run(shell: &Shell, args: Args) -> Result<()> {
let project_root = std::env::current_dir()?;
let entries = top_level_entries(&project_root)?;
let answers = resolve_answers(shell, &args, &entries)?;
let actions = plan_init(&project_root, &entries, &answers)?;
let package_manager = answers
.package_manager
.expect("resolve_answers always settles the package manager");
super::web::ensure_installed(package_manager).await?;
let name = match &args.name {
Some(name) => name.clone(),
None => project_root
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.ok_or_else(|| eyre!("cannot derive a project name; pass one as an argument"))?,
};
header!(
shell,
"Initializing WaterUI project in {}",
project_root.display()
);
let include_arg = execute_plan(
shell,
&project_root,
&actions,
package_manager,
&args,
&name,
)
.await?;
scaffold_shell(
shell,
&project_root,
&args,
package_manager,
&include_arg,
name,
)
.await?;
success!(shell, "WaterUI project initialized");
line!(shell);
line!(shell, "Next steps:");
line!(shell, " water run --platform <platform>");
Ok(())
}
fn top_level_entries(root: &std::path::Path) -> Result<Vec<String>> {
let mut entries = Vec::new();
for entry in std::fs::read_dir(root)? {
let entry = entry?;
entries.push(entry.file_name().to_string_lossy().into_owned());
}
entries.sort();
Ok(entries)
}
fn resolve_answers(shell: &Shell, args: &Args, entries: &[String]) -> Result<InitAnswers> {
let interactive = shell.is_interactive();
let has_frontend = entries.iter().any(|entry| entry == "package.json");
if !has_frontend && args.web.as_deref() == Some("new") && args.web_mode.is_some() {
bail!("--web-mode only applies to an existing project path");
}
let web = match (has_frontend, args.web.as_deref()) {
(true, _) => None,
(false, Some("new")) => Some(WebSource::New),
(false, Some(path)) => {
let source = PathBuf::from(path);
if !source.join("package.json").exists() {
bail!("{} has no package.json — it is not a web project", path);
}
Some(WebSource::Existing(source))
}
(false, None) if interactive => Some(prompt_web_source()?),
(false, None) => Some(WebSource::New),
};
let web_mode = match (&web, args.web_mode) {
(Some(WebSource::Existing(_)), Some(mode)) => Some(mode.into()),
(Some(WebSource::Existing(_)), None) if interactive => Some(prompt_web_mode()?),
(Some(WebSource::Existing(_)), None) => Some(ExistingFrontendMode::Copy),
_ => None,
};
let package_manager = match args.package_manager {
Some(pm) => Some(pm),
None if interactive => {
let initial = web::lockfile_package_manager(entries).unwrap_or_default();
Some(super::web::prompt_package_manager(initial)?)
}
None => Some(web::lockfile_package_manager(entries).unwrap_or_default()),
};
Ok(InitAnswers {
web,
web_mode,
package_manager,
})
}
fn prompt_web_source() -> Result<WebSource> {
let options = [
"Create a new Vite app in web/",
"Use an existing web project",
];
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("This directory has no web frontend")
.items(options)
.default(0)
.interact()?;
if selection == 0 {
return Ok(WebSource::New);
}
let path = Input::<String>::with_theme(&ColorfulTheme::default())
.with_prompt("Path to the existing project")
.interact_text()?;
let source = PathBuf::from(&path);
if !source.join("package.json").exists() {
bail!("{path} has no package.json — it is not a web project");
}
Ok(WebSource::Existing(source))
}
fn prompt_web_mode() -> Result<ExistingFrontendMode> {
let options = [
"Copy the project into web/",
"Reference it in place (include_web! points at it)",
];
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("How should the project join?")
.items(options)
.default(0)
.interact()?;
Ok(match selection {
0 => ExistingFrontendMode::Copy,
_ => ExistingFrontendMode::Reference,
})
}
async fn execute_plan(
shell: &Shell,
root: &std::path::Path,
actions: &[InitAction],
package_manager: PackageManager,
args: &Args,
display_name: &str,
) -> Result<String> {
let mut include_arg = None;
for action in actions {
match action {
InitAction::MoveFrontendToWeb { entries } => {
super::web::move_entries_to_web(root, entries).await?;
success!(shell, "Moved the frontend into web/");
}
InitAction::ScaffoldVite => {
super::web::create_vite(
shell,
root,
"web",
package_manager,
args.vite_template.as_deref(),
)
.await?;
super::web::brand_overlay(shell, &root.join("web"), display_name)?;
}
InitAction::CopyFrontend { source } => {
let dest = root.join("web");
super::web::copy_frontend(source, &dest).await?;
success!(shell, "Copied {} into web/", source.display());
}
InitAction::InstallDependencies => {
super::web::install_dependencies(shell, package_manager, &root.join("web")).await?;
}
InitAction::ScaffoldShell { web_arg } => {
include_arg = Some(web_arg.clone());
}
}
}
include_arg.ok_or_else(|| eyre!("the init plan produced no shell scaffold step"))
}
async fn scaffold_shell(
shell: &Shell,
root: &std::path::Path,
args: &Args,
package_manager: PackageManager,
include_arg: &str,
name: String,
) -> Result<()> {
let bundle_id = args
.bundle_id
.clone()
.unwrap_or_else(|| format!("dev.waterui.{}", name.to_snake_case()));
let spinner = shell.spinner("Scaffolding the Rust shell...");
let project = Project::init(
root,
CreateOptions {
name,
bundle_identifier: BundleIdentifier::try_from(bundle_id.as_str())
.map_err(|error| eyre!(error))?,
package_type: PackageType::App,
waterui_path: args.waterui_path.clone(),
channel: args.channel,
framework_manifest: args.framework_manifest.clone(),
framework: None,
author: whoami::username()
.map_err(|error| eyre!("Failed to determine project author: {error}"))?,
backends: Vec::new(),
web: Some(WebScaffold {
package_manager,
include_arg: include_arg.to_string(),
}),
},
)
.await?;
if let Some(pb) = spinner {
pb.finish_and_clear();
}
success!(shell, "Created Cargo.toml, src/lib.rs, and Water.toml");
drop(project);
Ok(())
}