diff --git a/crates/anyedge-cli/src/adapter.rs b/crates/anyedge-cli/src/adapter.rs index 889482d9..4d2a8773 100644 --- a/crates/anyedge-cli/src/adapter.rs +++ b/crates/anyedge-cli/src/adapter.rs @@ -223,4 +223,23 @@ mod tests { std::env::remove_var("ANYEDGE_TEST_SECRET"); } + + #[test] + fn shell_escape_quotes_and_spaces() { + assert_eq!(super::shell_escape("plain"), "plain"); + assert_eq!(super::shell_escape("with space"), "'with space'"); + assert_eq!(super::shell_escape("needs'quote"), "'needs'\"'\"'quote'"); + assert_eq!(super::shell_escape(""), "''"); + } + + #[test] + fn shell_join_combines_arguments_with_escaping() { + let args = vec![ + "plain".to_string(), + "with space".to_string(), + "needs'quote".to_string(), + ]; + let joined = super::shell_join(&args); + assert_eq!(joined, "plain 'with space' 'needs'\"'\"'quote'"); + } } diff --git a/crates/anyedge-cli/src/args.rs b/crates/anyedge-cli/src/args.rs index 4812ab80..43f83a2b 100644 --- a/crates/anyedge-cli/src/args.rs +++ b/crates/anyedge-cli/src/args.rs @@ -45,3 +45,51 @@ pub struct NewArgs { #[arg(long)] pub local_core: bool, } + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[test] + fn parses_new_command_with_defaults() { + let args = Args::try_parse_from(["anyedge", "new", "demo-app"]).expect("parse new"); + match args.cmd { + Command::New(new_args) => { + assert_eq!(new_args.name, "demo-app"); + assert!(new_args.dir.is_none()); + assert!(!new_args.local_core); + } + other => panic!("unexpected command: {other:?}"), + } + } + + #[test] + fn parses_build_command_with_passthrough_args() { + let args = Args::try_parse_from([ + "anyedge", + "build", + "--adapter", + "fastly", + "--", + "--flag", + "value", + ]) + .expect("parse build"); + match args.cmd { + Command::Build { + adapter, + adapter_args, + } => { + assert_eq!(adapter, "fastly"); + assert_eq!(adapter_args, vec!["--flag", "value"]); + } + other => panic!("unexpected command: {other:?}"), + } + } + + #[test] + fn missing_required_adapter_returns_error() { + assert!(Args::try_parse_from(["anyedge", "build"]).is_err()); + } +} diff --git a/crates/anyedge-cli/src/dev_server.rs b/crates/anyedge-cli/src/dev_server.rs index c1553eaf..3f505f7e 100644 --- a/crates/anyedge-cli/src/dev_server.rs +++ b/crates/anyedge-cli/src/dev_server.rs @@ -52,8 +52,14 @@ fn handle_conn(stream: &mut TcpStream, router: RouterService) -> std::io::Result } } - let req_text = String::from_utf8_lossy(&buf[..read]); - let mut lines = req_text.split("\r\n"); + let request = request_from_buffer(&buf[..read])?; + let response = block_on(router.oneshot(request)); + write_response(stream, response) +} + +fn request_from_buffer(raw: &[u8]) -> std::io::Result { + let req_text = String::from_utf8_lossy(raw); + let mut lines = req_text.lines(); let request_line = lines.next().unwrap_or(""); let mut parts = request_line.split_whitespace(); let method_token = parts.next().unwrap_or("GET"); @@ -84,30 +90,42 @@ fn handle_conn(stream: &mut TcpStream, router: RouterService) -> std::io::Result } } - let response = block_on(router.oneshot(req)); - write_response(stream, response) + Ok(req) } fn write_response(stream: &mut TcpStream, response: CoreResponse) -> std::io::Result<()> { + let (head, body) = serialize_response(response)?; + stream.write_all(&head)?; + stream.write_all(&body)?; + Ok(()) +} + +fn serialize_response(response: CoreResponse) -> std::io::Result<(Vec, Vec)> { let (parts, body) = response.into_parts(); let status = parts.status; let reason = status.canonical_reason().unwrap_or("OK"); - let mut out = Vec::new(); - out.extend_from_slice(format!("HTTP/1.1 {} {}\r\n", status.as_u16(), reason).as_bytes()); + let mut head = Vec::new(); + head.extend_from_slice(b"HTTP/1.1 "); + let status_code = status.as_u16().to_string(); + head.extend_from_slice(status_code.as_bytes()); + head.push(b' '); + head.extend_from_slice(reason.as_bytes()); + head.extend_from_slice(b"\r\n"); let mut has_content_length = false; for (name, value) in parts.headers.iter() { if name.as_str().eq_ignore_ascii_case("content-length") { has_content_length = true; } - out.extend_from_slice( - format!("{}: {}\r\n", name.as_str(), value.to_str().unwrap_or("")).as_bytes(), - ); + head.extend_from_slice(name.as_str().as_bytes()); + head.extend_from_slice(b": "); + head.extend_from_slice(value.to_str().unwrap_or("").as_bytes()); + head.extend_from_slice(b"\r\n"); } - let body_bytes = match body { - Body::Once(bytes) => bytes, + let body_bytes: Vec = match body { + Body::Once(bytes) => bytes.to_vec(), Body::Stream(stream_body) => { let collected = block_on(async move { let mut buf = Vec::new(); @@ -118,18 +136,19 @@ fn write_response(stream: &mut TcpStream, response: CoreResponse) -> std::io::Re } Ok::, std::io::Error>(buf) })?; - collected.into() + collected } }; if !has_content_length { - out.extend_from_slice(format!("Content-Length: {}\r\n", body_bytes.len()).as_bytes()); + head.extend_from_slice(b"Content-Length: "); + head.extend_from_slice(body_bytes.len().to_string().as_bytes()); + head.extend_from_slice(b"\r\n"); } - out.extend_from_slice(b"\r\n"); - stream.write_all(&out)?; - stream.write_all(body_bytes.as_ref())?; - Ok(()) + head.extend_from_slice(b"\r\n"); + + Ok((head, body_bytes)) } fn build_dev_router() -> RouterService { @@ -172,3 +191,66 @@ async fn dev_root() -> Text<&'static str> { async fn dev_echo(Path(params): anyedge_core::extractor::Path) -> Text { Text::new(format!("hello {}", params.name)) } + +#[cfg(test)] +mod tests { + use super::*; + use anyedge_core::http::{header::HOST, response_builder, Method, StatusCode}; + use anyedge_core::response::Text; + + #[anyedge_core::action] + async fn hello() -> Text<&'static str> { + Text::new("hello world") + } + + #[test] + fn request_from_buffer_parses_basic_get() { + let request = request_from_buffer( + b"GET /demo HTTP/1.1 +Host: example + +", + ) + .expect("request"); + assert_eq!(request.method(), Method::GET); + assert_eq!(request.uri().path(), "/demo"); + assert_eq!( + request + .headers() + .get(HOST) + .and_then(|value| value.to_str().ok()), + Some("example") + ); + } + + #[test] + fn serialize_response_includes_headers_and_body() { + let response = response_builder() + .status(StatusCode::OK) + .header("x-test", "value") + .body(Body::text("hi")) + .expect("response"); + let (head, body) = serialize_response(response).expect("serialize"); + let head_text = String::from_utf8(head).expect("utf8"); + assert!(head_text.starts_with("HTTP/1.1 200 OK")); + assert!(head_text.contains("Content-Length: 2")); + assert!(head_text.contains("x-test: value")); + assert!(head_text.contains("\r\n\r\n")); + assert_eq!(body, b"hi"); + } + + #[test] + fn router_handles_request_via_helpers() { + let router = RouterService::builder().get("/", hello).build(); + let request = request_from_buffer( + b"GET / HTTP/1.1 +Host: localhost + +", + ) + .expect("request"); + let response = block_on(router.oneshot(request)); + let (_head, body) = serialize_response(response).expect("serialize"); + assert_eq!(body, b"hello world"); + } +} diff --git a/crates/anyedge-cli/src/generator.rs b/crates/anyedge-cli/src/generator.rs index 1da808d5..dddf21f6 100644 --- a/crates/anyedge-cli/src/generator.rs +++ b/crates/anyedge-cli/src/generator.rs @@ -8,7 +8,7 @@ use handlebars::Handlebars; use serde_json::{Map, Value}; use std::collections::BTreeMap; use std::fmt::Write as _; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Command; struct AdapterContext<'a> { @@ -17,77 +17,146 @@ struct AdapterContext<'a> { data_entries: Vec<(String, String)>, } -pub fn generate_new(args: NewArgs) -> std::io::Result<()> { - use std::fs; - - let name = sanitize_crate_name(&args.name); - let base_dir = args - .dir - .as_deref() - .map(PathBuf::from) - .unwrap_or_else(|| std::env::current_dir().unwrap()); - let out_dir = base_dir.join(&name); - if out_dir.exists() { - return Err(std::io::Error::new( - std::io::ErrorKind::AlreadyExists, - format!("directory '{}' already exists", out_dir.display()), - )); +struct ProjectLayout { + name: String, + out_dir: PathBuf, + crates_dir: PathBuf, + core_name: String, + core_dir: PathBuf, + project_mod: String, + core_mod: String, +} + +impl ProjectLayout { + fn new(args: &NewArgs) -> std::io::Result { + let name = sanitize_crate_name(&args.name); + let base_dir = args + .dir + .as_deref() + .map(PathBuf::from) + .unwrap_or_else(|| std::env::current_dir().unwrap()); + let out_dir = base_dir.join(&name); + if out_dir.exists() { + return Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + format!("directory '{}' already exists", out_dir.display()), + )); + } + + println!("[anyedge] creating project at {}", out_dir.display()); + + let crates_dir = out_dir.join("crates"); + let core_name = format!("{}-core", name); + let core_dir = crates_dir.join(&core_name); + std::fs::create_dir_all(core_dir.join("src"))?; + + Ok(ProjectLayout { + project_mod: name.replace('-', "_"), + core_mod: core_name.replace('-', "_"), + core_name, + core_dir, + crates_dir, + out_dir, + name, + }) } +} - // Create base dirs for multi-crate workspace - let crates_dir = out_dir.join("crates"); - let core_name = format!("{}-core", name); - let core_dir = crates_dir.join(&core_name); - - fs::create_dir_all(core_dir.join("src"))?; - - let mut workspace_dependencies: BTreeMap = BTreeMap::new(); - workspace_dependencies - .entry("bytes".to_string()) - .or_insert_with(|| "bytes = \"1\"".to_string()); - workspace_dependencies - .entry("futures".to_string()) - .or_insert_with(|| { - "futures = { version = \"0.3\", default-features = false, features = [\"std\", \"executor\"] }" - .to_string() - }); - workspace_dependencies - .entry("serde".to_string()) - .or_insert_with(|| "serde = { version = \"1\", features = [\"derive\"] }".to_string()); - workspace_dependencies - .entry("log".to_string()) - .or_insert_with(|| "log = \"0.4\"".to_string()); - workspace_dependencies - .entry("worker".to_string()) - .or_insert_with(|| { - "worker = { version = \"0.6\", default-features = false, features = [\"http\"] }" - .to_string() - }); - workspace_dependencies - .entry("fastly".to_string()) - .or_insert_with(|| "fastly = \"0.11\"".to_string()); - workspace_dependencies - .entry("once_cell".to_string()) - .or_insert_with(|| "once_cell = \"1\"".to_string()); - - // Resolve path dependencies to anyedge crates if building inside this repo +struct AdapterArtifacts { + contexts: Vec>, + adapter_ids: Vec, + workspace_members: Vec, + manifest_sections: String, + logging_sections: String, + readme_adapter_crates: String, + readme_adapter_dev: String, +} + +pub fn generate_new(args: NewArgs) -> std::io::Result<()> { + let layout = ProjectLayout::new(&args)?; + + let mut workspace_dependencies = seed_workspace_dependencies(); let cwd = std::env::current_dir().unwrap(); + let core_crate_line = resolve_core_dependency(&layout, &cwd, &mut workspace_dependencies); + + let adapter_artifacts = collect_adapter_data(&layout, &cwd, &mut workspace_dependencies)?; + + let mut data_map = build_base_data( + &layout, + &core_crate_line, + &adapter_artifacts, + &workspace_dependencies, + ); + + for context in &adapter_artifacts.contexts { + for (key, value) in &context.data_entries { + data_map.insert(key.clone(), Value::String(value.clone())); + } + } + + let data_value = Value::Object(data_map); + + render_templates(&layout, &adapter_artifacts.contexts, &data_value)?; + initialize_git_repo(&layout.out_dir); + + println!( + "[anyedge] created new multi-crate app at {}", + layout.out_dir.display() + ); + + Ok(()) +} + +fn seed_workspace_dependencies() -> BTreeMap { + let mut deps = BTreeMap::new(); + deps.insert("bytes".to_string(), "bytes = \"1\"".to_string()); + deps.insert( + "futures".to_string(), + "futures = { version = \"0.3\", default-features = false, features = [\"std\", \"executor\"] }" + .to_string(), + ); + deps.insert( + "serde".to_string(), + "serde = { version = \"1\", features = [\"derive\"] }".to_string(), + ); + deps.insert("log".to_string(), "log = \"0.4\"".to_string()); + deps.insert( + "worker".to_string(), + "worker = { version = \"0.6\", default-features = false, features = [\"http\"] }" + .to_string(), + ); + deps.insert("fastly".to_string(), "fastly = \"0.11\"".to_string()); + deps.insert("once_cell".to_string(), "once_cell = \"1\"".to_string()); + deps +} + +fn resolve_core_dependency( + layout: &ProjectLayout, + cwd: &Path, + workspace_dependencies: &mut BTreeMap, +) -> String { let ResolvedDependency { - name: core_dep_name, - workspace_line: core_workspace_line, - crate_line: core_crate_line, + name, + workspace_line, + crate_line, } = resolve_dep_line( - &out_dir, - &cwd, + &layout.out_dir, + cwd, "crates/anyedge-core", "anyedge-core = { git = \"ssh://git@github.com/stackpop/anyedge.git\", package = \"anyedge-core\", default-features = false }", &[], ); - workspace_dependencies - .entry(core_dep_name) - .or_insert(core_workspace_line); - let project_module_name = name.replace('-', "_"); - let mut adapter_contexts = Vec::new(); + + workspace_dependencies.entry(name).or_insert(workspace_line); + crate_line +} + +fn collect_adapter_data( + layout: &ProjectLayout, + cwd: &Path, + workspace_dependencies: &mut BTreeMap, +) -> std::io::Result { + let mut contexts = Vec::new(); let mut adapter_ids = Vec::new(); let mut workspace_members = Vec::new(); let mut manifest_sections = String::new(); @@ -98,10 +167,11 @@ pub fn generate_new(args: NewArgs) -> std::io::Result<()> { let blueprints = scaffold::registered_blueprints(); for blueprint in blueprints.iter().copied() { - let crate_name = format!("{}-{}", name, blueprint.crate_suffix); - let adapter_dir = crates_dir.join(&crate_name); + let crate_name = format!("{}-{}", layout.name, blueprint.crate_suffix); + let adapter_dir = layout.crates_dir.join(&crate_name); + std::fs::create_dir_all(&adapter_dir)?; for dir_name in blueprint.extra_dirs { - fs::create_dir_all(adapter_dir.join(dir_name))?; + std::fs::create_dir_all(adapter_dir.join(dir_name))?; } let mut data_entries: Vec<(String, String)> = Vec::new(); @@ -112,7 +182,13 @@ pub fn generate_new(args: NewArgs) -> std::io::Result<()> { name, workspace_line, crate_line, - } = resolve_dep_line(&out_dir, &cwd, dep.repo_crate, dep.fallback, dep.features); + } = resolve_dep_line( + &layout.out_dir, + cwd, + dep.repo_crate, + dep.fallback, + dep.features, + ); workspace_dependencies.entry(name).or_insert(workspace_line); data_entries.push((dep.key.to_string(), crate_line)); } @@ -169,12 +245,7 @@ pub fn generate_new(args: NewArgs) -> std::io::Result<()> { let mut logging_section = String::new(); writeln!(logging_section, "[logging.{}]", blueprint.id).unwrap(); if blueprint.id == "fastly" { - writeln!( - logging_section, - "endpoint = \"{}_log\"", - project_module_name - ) - .unwrap(); + writeln!(logging_section, "endpoint = \"{}_log\"", layout.project_mod).unwrap(); } else if let Some(endpoint) = blueprint.logging.endpoint { writeln!(logging_section, "endpoint = \"{}\"", endpoint).unwrap(); } @@ -214,24 +285,45 @@ pub fn generate_new(args: NewArgs) -> std::io::Result<()> { workspace_members.push(format!(" \"crates/{}\",", crate_name)); adapter_ids.push(blueprint.id.to_string()); - adapter_contexts.push(AdapterContext { + contexts.push(AdapterContext { blueprint, dir: adapter_dir, data_entries, }); } - // Prepare template data map + Ok(AdapterArtifacts { + contexts, + adapter_ids, + workspace_members, + manifest_sections, + logging_sections, + readme_adapter_crates, + readme_adapter_dev, + }) +} + +fn build_base_data( + layout: &ProjectLayout, + core_crate_line: &str, + artifacts: &AdapterArtifacts, + workspace_dependencies: &BTreeMap, +) -> Map { let mut data = Map::new(); - data.insert("name".into(), Value::String(name.clone())); - let core_module_name = core_name.replace('-', "_"); - let project_module_name = name.replace('-', "_"); - data.insert("proj_core".into(), Value::String(core_name.clone())); - data.insert("proj_core_mod".into(), Value::String(core_module_name)); - data.insert("proj_mod".into(), Value::String(project_module_name)); - data.insert("dep_anyedge_core".into(), Value::String(core_crate_line)); - - let adapter_list_str = adapter_ids + data.insert("name".into(), Value::String(layout.name.clone())); + data.insert("proj_core".into(), Value::String(layout.core_name.clone())); + data.insert( + "proj_core_mod".into(), + Value::String(layout.core_mod.clone()), + ); + data.insert("proj_mod".into(), Value::String(layout.project_mod.clone())); + data.insert( + "dep_anyedge_core".into(), + Value::String(core_crate_line.to_string()), + ); + + let adapter_list_str = artifacts + .adapter_ids .iter() .map(|id| format!("\"{}\"", id)) .collect::>() @@ -239,28 +331,25 @@ pub fn generate_new(args: NewArgs) -> std::io::Result<()> { data.insert("adapter_list".into(), Value::String(adapter_list_str)); data.insert( "workspace_members".into(), - Value::String(workspace_members.join("\n")), + Value::String(artifacts.workspace_members.join("\n")), ); data.insert( "adapter_manifest_sections".into(), - Value::String(manifest_sections), + Value::String(artifacts.manifest_sections.clone()), + ); + data.insert( + "logging_sections".into(), + Value::String(artifacts.logging_sections.clone()), ); - data.insert("logging_sections".into(), Value::String(logging_sections)); data.insert( "readme_adapter_crates".into(), - Value::String(readme_adapter_crates), + Value::String(artifacts.readme_adapter_crates.clone()), ); data.insert( "readme_adapter_dev".into(), - Value::String(readme_adapter_dev), + Value::String(artifacts.readme_adapter_dev.clone()), ); - for context in &adapter_contexts { - for (key, value) in &context.data_entries { - data.insert(key.clone(), Value::String(value.clone())); - } - } - let workspace_dep_lines = workspace_dependencies .values() .cloned() @@ -271,83 +360,199 @@ pub fn generate_new(args: NewArgs) -> std::io::Result<()> { Value::String(workspace_dep_lines), ); - let data_value = Value::Object(data.clone()); + data +} - // Render all templates +fn render_templates( + layout: &ProjectLayout, + adapter_contexts: &[AdapterContext], + data_value: &Value, +) -> std::io::Result<()> { let mut hbs = Handlebars::new(); register_templates(&mut hbs); - // Root workspace files + println!("[anyedge] writing workspace files"); write_tmpl( &hbs, "root_Cargo_toml", - &data_value, - &out_dir.join("Cargo.toml"), + data_value, + &layout.out_dir.join("Cargo.toml"), )?; write_tmpl( &hbs, "root_anyedge_toml", - &data_value, - &out_dir.join("anyedge.toml"), + data_value, + &layout.out_dir.join("anyedge.toml"), )?; write_tmpl( &hbs, "root_README_md", - &data_value, - &out_dir.join("README.md"), + data_value, + &layout.out_dir.join("README.md"), )?; write_tmpl( &hbs, "root_gitignore", - &data_value, - &out_dir.join(".gitignore"), + data_value, + &layout.out_dir.join(".gitignore"), )?; - // Core crate + println!("[anyedge] writing core crate {}", layout.core_name); write_tmpl( &hbs, "core_Cargo_toml", - &data_value, - &core_dir.join("Cargo.toml"), + data_value, + &layout.core_dir.join("Cargo.toml"), )?; write_tmpl( &hbs, "core_src_lib_rs", - &data_value, - &core_dir.join("src/lib.rs"), + data_value, + &layout.core_dir.join("src/lib.rs"), )?; write_tmpl( &hbs, "core_src_handlers_rs", - &data_value, - &core_dir.join("src/handlers.rs"), + data_value, + &layout.core_dir.join("src/handlers.rs"), )?; - for context in &adapter_contexts { + for context in adapter_contexts { + println!( + "[anyedge] writing adapter crate {}", + context.dir.file_name().unwrap().to_string_lossy() + ); for file in context.blueprint.files { write_tmpl( &hbs, file.template, - &data_value, + data_value, &context.dir.join(file.output), )?; } } - if let Err(err) = Command::new("git") + Ok(()) +} + +fn initialize_git_repo(out_dir: &Path) { + println!("[anyedge] initializing git repository"); + match Command::new("git") .arg("init") - .current_dir(&out_dir) + .arg("--quiet") + .current_dir(out_dir) .status() { - eprintln!( - "[anyedge] warning: failed to initialize git repository: {}", - err - ); + Ok(status) if status.success() => { + println!( + "[anyedge] initialized empty Git repository in {}/.git/", + out_dir.display() + ); + } + Ok(status) => { + eprintln!("[anyedge] warning: git init exited with status {status}"); + } + Err(err) => { + eprintln!( + "[anyedge] warning: failed to initialize git repository: {}", + err + ); + } } +} - println!( - "[anyedge] created new multi-crate app at {}", - out_dir.display() - ); - Ok(()) +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + use tempfile::TempDir; + + struct PathOverride { + original: Option, + } + + impl PathOverride { + fn prepend(path: &Path) -> Self { + let original = std::env::var("PATH").ok(); + let sep = if cfg!(windows) { ";" } else { ":" }; + let prefix = path.to_string_lossy(); + let new_path = match &original { + Some(existing) if !existing.is_empty() => format!("{prefix}{sep}{existing}"), + _ => prefix.into_owned(), + }; + std::env::set_var("PATH", &new_path); + Self { original } + } + } + + impl Drop for PathOverride { + fn drop(&mut self) { + if let Some(ref original) = self.original { + std::env::set_var("PATH", original); + } else { + std::env::remove_var("PATH"); + } + } + } + + #[test] + fn generate_new_scaffolds_workspace_layout() { + let temp = TempDir::new().expect("temp dir"); + let bin_dir = temp.path().join("bin"); + std::fs::create_dir_all(&bin_dir).expect("bin dir"); + let git_path = if cfg!(windows) { + bin_dir.join("git.cmd") + } else { + bin_dir.join("git") + }; + + if cfg!(windows) { + std::fs::write(&git_path, b"@echo off\r\nexit /b 0\r\n").expect("write git stub"); + } else { + std::fs::write(&git_path, b"#!/bin/sh\nexit 0\n").expect("write git stub"); + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&git_path) + .expect("metadata") + .permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&git_path, perms).expect("chmod"); + } + + let _path_guard = PathOverride::prepend(&bin_dir); + + let args = NewArgs { + name: "demo-app".into(), + dir: Some(temp.path().to_string_lossy().into_owned()), + local_core: false, + }; + + generate_new(args).expect("scaffold succeeds"); + + let project_dir = temp.path().join("demo-app"); + assert!(project_dir.is_dir(), "project directory created"); + assert!(project_dir.join("Cargo.toml").exists()); + assert!(project_dir.join("anyedge.toml").exists()); + assert!(project_dir.join(".gitignore").exists()); + assert!(project_dir.join("README.md").exists()); + assert!(project_dir.join("crates/demo-app-core/src/lib.rs").exists()); + + let cargo_toml = + std::fs::read_to_string(project_dir.join("Cargo.toml")).expect("read Cargo.toml"); + assert!(cargo_toml.contains("crates/demo-app-core")); + assert!(cargo_toml.contains("crates/demo-app-adapter-cloudflare")); + assert!(cargo_toml.contains("crates/demo-app-adapter-fastly")); + + let manifest = + std::fs::read_to_string(project_dir.join("anyedge.toml")).expect("read anyedge.toml"); + assert!(manifest.contains("[adapters.cloudflare.adapter]")); + assert!(manifest.contains("[adapters.fastly.adapter]")); + + let gitignore = + std::fs::read_to_string(project_dir.join(".gitignore")).expect("read .gitignore"); + assert!(gitignore.contains("target/")); + } } diff --git a/crates/anyedge-cli/src/main.rs b/crates/anyedge-cli/src/main.rs index 09bcf060..63542df6 100644 --- a/crates/anyedge-cli/src/main.rs +++ b/crates/anyedge-cli/src/main.rs @@ -141,3 +141,141 @@ fn load_manifest_optional() -> Result, String> { Err(err) => Err(format!("failed to load {}: {err}", path.display())), } } + +#[cfg(all(test, feature = "cli"))] +mod tests { + use super::*; + use anyedge_core::manifest::ManifestLoader; + use std::fs; + use std::sync::{Mutex, OnceLock}; + use tempfile::TempDir; + + const BASIC_MANIFEST: &str = r#" +[app] +name = "demo-app" +entry = "crates/demo-core" + +[adapters.fastly.adapter] +crate = "crates/demo-fastly" +manifest = "crates/demo-fastly/fastly.toml" + +[adapters.fastly.build] +target = "wasm32-unknown-unknown" +profile = "release" + +[adapters.fastly.commands] +build = "echo build" +deploy = "echo deploy" +serve = "echo serve" +"#; + + fn manifest_guard() -> &'static Mutex<()> { + static GUARD: OnceLock> = OnceLock::new(); + GUARD.get_or_init(|| Mutex::new(())) + } + + struct EnvOverride { + key: &'static str, + original: Option, + } + + impl EnvOverride { + fn set(key: &'static str, value: &str) -> Self { + let original = std::env::var(key).ok(); + std::env::set_var(key, value); + Self { key, original } + } + } + + impl Drop for EnvOverride { + fn drop(&mut self) { + if let Some(ref original) = self.original { + std::env::set_var(self.key, original); + } else { + std::env::remove_var(self.key); + } + } + } + + #[test] + fn load_manifest_optional_returns_none_when_missing() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().expect("temp dir"); + let manifest_path = temp.path().join("missing.toml"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("ANYEDGE_MANIFEST", &manifest_str); + let result = load_manifest_optional().expect("load result"); + assert!(result.is_none()); + } + + #[test] + fn load_manifest_optional_reads_manifest() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().expect("temp dir"); + let manifest_path = temp.path().join("anyedge.toml"); + fs::write(&manifest_path, BASIC_MANIFEST).expect("write manifest"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("ANYEDGE_MANIFEST", &manifest_str); + let manifest = load_manifest_optional() + .expect("load result") + .expect("manifest present"); + assert!(manifest.manifest().adapters.contains_key("fastly")); + } + + #[test] + fn ensure_adapter_defined_accepts_known_adapter() { + let loader = ManifestLoader::load_from_str(BASIC_MANIFEST); + assert!(ensure_adapter_defined("fastly", Some(&loader)).is_ok()); + } + + #[test] + fn ensure_adapter_defined_reports_unknown_adapter() { + let loader = ManifestLoader::load_from_str(BASIC_MANIFEST); + let err = ensure_adapter_defined("cloudflare", Some(&loader)).expect_err("should err"); + assert!(err.contains("available")); + assert!(err.contains("fastly")); + } + + #[test] + fn ensure_adapter_defined_allows_when_manifest_missing() { + assert!(ensure_adapter_defined("fastly", None).is_ok()); + } + + #[cfg(not(windows))] + #[test] + fn handle_build_executes_manifest_command() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().expect("temp dir"); + let manifest_path = temp.path().join("anyedge.toml"); + fs::write(&manifest_path, BASIC_MANIFEST).expect("write manifest"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("ANYEDGE_MANIFEST", &manifest_str); + let args: Vec = Vec::new(); + handle_build("fastly", &args).expect("build command runs"); + } + + #[cfg(not(windows))] + #[test] + fn handle_deploy_executes_manifest_command() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().expect("temp dir"); + let manifest_path = temp.path().join("anyedge.toml"); + fs::write(&manifest_path, BASIC_MANIFEST).expect("write manifest"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("ANYEDGE_MANIFEST", &manifest_str); + let args: Vec = Vec::new(); + handle_deploy("fastly", &args).expect("deploy command runs"); + } + + #[cfg(not(windows))] + #[test] + fn handle_serve_executes_manifest_command() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().expect("temp dir"); + let manifest_path = temp.path().join("anyedge.toml"); + fs::write(&manifest_path, BASIC_MANIFEST).expect("write manifest"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("ANYEDGE_MANIFEST", &manifest_str); + handle_serve("fastly").expect("serve command runs"); + } +} diff --git a/crates/anyedge-cli/src/scaffold.rs b/crates/anyedge-cli/src/scaffold.rs index 409721d8..34ddca77 100644 --- a/crates/anyedge-cli/src/scaffold.rs +++ b/crates/anyedge-cli/src/scaffold.rs @@ -156,3 +156,37 @@ pub fn relative_to(from: &std::path::Path, to: &std::path::Path) -> Option