use clap::{Parser, Subcommand};
use crate::error::CliError;
#[derive(Parser)]
#[command(name = "adapto", version, about = "Adapto Live Runtime CLI")]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
}
#[derive(Debug, Subcommand)]
pub enum Commands {
New {
name: String,
},
Dev {
#[arg(short, long, default_value = "3000")]
port: u16,
#[arg(long, default_value = "127.0.0.1")]
host: String,
},
Build {
#[arg(long)]
release: bool,
},
Check,
Generate {
#[command(subcommand)]
resource: GenerateCommand,
},
Routes,
Doctor,
}
#[derive(Debug, Subcommand)]
pub enum GenerateCommand {
Resource {
name: String,
},
}
pub async fn run(cli: Cli) -> Result<(), CliError> {
match cli.command {
Commands::New { name } => cmd_new(&name),
Commands::Dev { port, host } => cmd_dev(&host, port).await,
Commands::Build { release } => cmd_build(release),
Commands::Check => cmd_check(),
Commands::Generate { resource } => cmd_generate(resource),
Commands::Routes => cmd_routes(),
Commands::Doctor => cmd_doctor(),
}
}
fn cmd_new(name: &str) -> Result<(), CliError> {
let dirs = [
format!("{name}"),
format!("{name}/app"),
format!("{name}/app/dashboard"),
format!("{name}/components"),
format!("{name}/resources"),
format!("{name}/public"),
format!("{name}/tests"),
];
for dir in &dirs {
std::fs::create_dir_all(dir).map_err(|e| CliError::IoError(e.to_string()))?;
}
let config = format!(
r#"[app]
name = "{name}"
env = "development"
[server]
host = "0.0.0.0"
port = 3000
[database]
url = "postgres://localhost/{name}"
[security]
csrf = true
secure_cookies = true
content_security_policy = "strict"
[live]
websocket_path = "/_adapto/live"
max_sessions_per_user = 10
event_rate_limit_per_second = 20
[tenant]
mode = "optional"
strategy = "subdomain"
"#
);
std::fs::write(format!("{name}/adapto.toml"), config)
.map_err(|e| CliError::IoError(e.to_string()))?;
let root_page = r#"<route>
path: "/"
layout: "main"
auth: public
</route>
<script lang="rust">
state greeting: String = "Welcome to Adapto!"
</script>
<template>
<main>
<h1>{greeting}</h1>
<p>Edit app/page.adapto to get started.</p>
</main>
</template>
<style scoped>
main {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
</style>
"#;
std::fs::write(format!("{name}/app/page.adapto"), root_page)
.map_err(|e| CliError::IoError(e.to_string()))?;
let layout = r#"<layout name="main">
auth: public
</layout>
<template>
<html>
<head>
<meta charset="utf-8" />
<title>Adapto App</title>
</head>
<body>
<slot />
</body>
</html>
</template>
"#;
std::fs::write(format!("{name}/app/layout.adapto"), layout)
.map_err(|e| CliError::IoError(e.to_string()))?;
println!("Created new Adapto project: {name}");
println!();
println!(" cd {name}");
println!(" adapto dev");
println!();
Ok(())
}
async fn cmd_dev(host: &str, port: u16) -> Result<(), CliError> {
let project = compile_project(".")?;
let route_count = project.route_manifest.routes.len();
let resource_count = project.resource_managers.len();
let file_count = project.file_count;
let mut page_renderer = project.page_renderer;
for (id, graph) in &project.dependency_graphs {
page_renderer.register_dependency_graph(id, graph.clone());
}
let session_manager = adapto_live::manager::SessionManager::new(100);
let server = adapto_ssr::server::AdaptoServer::new(
page_renderer,
session_manager,
20,
b"adapto-dev-secret".to_vec(),
);
println!("Adapto dev server starting...");
println!(" Files: {file_count}");
println!(" Routes: {route_count}");
println!(" Resources: {resource_count}");
println!();
println!(" http://{host}:{port}");
println!();
println!("Press Ctrl+C to stop.");
server
.serve(host, port)
.await
.map_err(|e| CliError::IoError(e.to_string()))
}
fn cmd_build(release: bool) -> Result<(), CliError> {
let project = compile_project(".")?;
let mode = if release { "release" } else { "debug" };
println!("Build complete ({mode}).");
println!(" Files: {}", project.file_count);
println!(" Components: {}", project.component_irs.len());
println!(" Routes: {}", project.route_manifest.routes.len());
println!(" Resources: {}", project.resource_managers.len());
println!();
for route in &project.route_manifest.routes {
println!(" {} {} [{}]", route.method, route.path, route.auth);
}
Ok(())
}
fn cmd_check() -> Result<(), CliError> {
let errors = check_project()?;
if errors.is_empty() {
println!("All checks passed.");
} else {
for error in &errors {
eprintln!("{error}");
}
return Err(CliError::CheckFailed(errors.len()));
}
Ok(())
}
fn cmd_generate(resource: GenerateCommand) -> Result<(), CliError> {
match resource {
GenerateCommand::Resource { name } => {
let lower = name.to_lowercase();
let resource_content = format!(
r#"<resource name="{name}" table="{lower}s">
tenant: required
primary_key: id
field id: Uuid readonly
field name: String required max=120
field created_at: DateTime readonly
permission read: "{lower}s.read"
permission create: "{lower}s.create"
permission update: "{lower}s.update"
permission delete: "{lower}s.delete"
</resource>
"#
);
let path = format!("resources/{name}.adapto");
std::fs::create_dir_all("resources")
.map_err(|e| CliError::IoError(e.to_string()))?;
std::fs::write(&path, resource_content)
.map_err(|e| CliError::IoError(e.to_string()))?;
let page_content = format!(
r#"<route>
path: "/{lower}s"
layout: "dashboard"
auth: required
tenant: required
permission: "{lower}s.read"
</route>
<script lang="rust">
state items: Vec<{name}> = []
state query: String = ""
load async fn load(ctx: Ctx) {{
items = {name}Repo::for_tenant(ctx.tenant_id).await?;
}}
action async fn search(ctx: Ctx) {{
items = {name}Repo::search(ctx.tenant_id, query.clone()).await?;
}}
</script>
<template>
<Page title="{name}s">
<Toolbar>
<Input bind:value="query" on:input.debounce.300="search" placeholder="Search..." />
</Toolbar>
<Table rows={{items}}>
<Column label="Name">{{row.name}}</Column>
</Table>
</Page>
</template>
"#
);
let page_dir = format!("app/{lower}s");
std::fs::create_dir_all(&page_dir)
.map_err(|e| CliError::IoError(e.to_string()))?;
std::fs::write(format!("{page_dir}/page.adapto"), page_content)
.map_err(|e| CliError::IoError(e.to_string()))?;
println!("Generated resource: {name}");
println!(" resources/{name}.adapto");
println!(" app/{lower}s/page.adapto");
}
}
Ok(())
}
fn cmd_routes() -> Result<(), CliError> {
let routes = find_route_files()?;
if routes.is_empty() {
println!("No routes found. Create .adapto files in app/ directory.");
} else {
println!(
"{:<30} {:<15} {:<10} {}",
"PATH", "AUTH", "TENANT", "FILE"
);
println!("{}", "-".repeat(80));
for (path, auth, tenant, file) in &routes {
println!("{path:<30} {auth:<15} {tenant:<10} {file}");
}
}
Ok(())
}
fn cmd_doctor() -> Result<(), CliError> {
println!("Adapto Doctor");
println!("=============");
let config_exists = std::path::Path::new("adapto.toml").exists();
println!(
"[{}] adapto.toml exists",
if config_exists { "OK" } else { "FAIL" }
);
let app_exists = std::path::Path::new("app").is_dir();
println!(
"[{}] app/ directory exists",
if app_exists { "OK" } else { "FAIL" }
);
let adapto_files = find_adapto_files().unwrap_or_default();
println!(
"[{}] .adapto files found: {}",
if !adapto_files.is_empty() {
"OK"
} else {
"WARN"
},
adapto_files.len()
);
match check_project() {
Ok(errors) => {
if errors.is_empty() {
println!("[OK] All files parse successfully");
} else {
println!("[FAIL] {} parse error(s)", errors.len());
}
}
Err(_) => println!("[FAIL] Parse check failed"),
}
println!();
if config_exists && app_exists {
println!("Project looks healthy!");
} else {
println!("Run `adapto new <name>` to create a project.");
}
Ok(())
}
fn compile_project(path: &str) -> Result<adapto_ssr::project::CompiledProject, CliError> {
adapto_ssr::project::ProjectLoader::load_project(path, b"adapto-dev-secret")
.map_err(|e| CliError::CompileError(e.to_string()))
}
pub fn find_adapto_files() -> Result<Vec<String>, CliError> {
let mut files = Vec::new();
walk_adapto(std::path::Path::new("."), &mut files);
files.sort();
Ok(files)
}
fn walk_adapto(dir: &std::path::Path, files: &mut Vec<String>) {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk_adapto(&path, files);
} else if path.extension().and_then(|e| e.to_str()) == Some("adapto") {
files.push(path.to_string_lossy().to_string());
}
}
}
}
fn parse_project() -> Result<usize, CliError> {
let files = find_adapto_files()?;
for file_path in &files {
let content =
std::fs::read_to_string(file_path).map_err(|e| CliError::IoError(e.to_string()))?;
adapto_parser::parse(&content)
.map_err(|e| CliError::CompileError(format!("{file_path}: {e}")))?;
}
Ok(files.len())
}
fn check_project() -> Result<Vec<String>, CliError> {
let files = find_adapto_files()?;
let mut errors = Vec::new();
for file_path in &files {
let content =
std::fs::read_to_string(file_path).map_err(|e| CliError::IoError(e.to_string()))?;
if let Err(e) = adapto_parser::parse(&content) {
errors.push(format!("{file_path}: {e}"));
}
}
Ok(errors)
}
fn find_route_files() -> Result<Vec<(String, String, String, String)>, CliError> {
let files = find_adapto_files()?;
let mut routes = Vec::new();
for file_path in &files {
let content =
std::fs::read_to_string(file_path).map_err(|e| CliError::IoError(e.to_string()))?;
if let Ok(ast) = adapto_parser::parse(&content) {
if let Some(route) = &ast.route {
let path = route.path.clone().unwrap_or_else(|| "?".to_string());
let auth = route
.auth
.as_ref()
.map(|a| format!("{a:?}"))
.unwrap_or_else(|| "public".to_string());
let tenant = route
.tenant
.as_ref()
.map(|t| format!("{t:?}"))
.unwrap_or_else(|| "none".to_string());
routes.push((path, auth, tenant, file_path.clone()));
}
}
}
Ok(routes)
}