Skip to main content

ecat_cli/
lib.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2
3/// Validate a project name for `ecat new`.
4///
5/// Rejects path separators (`/`, `\`), quotes, newlines and any character
6/// outside `^[a-z][a-z0-9_-]*$`, so the name can be safely used as a
7/// directory and embedded in a generated Cargo.toml.
8pub fn validate_crate_name(name: &str) -> Result<(), String> {
9    if name.is_empty() {
10        return Err("name must not be empty".into());
11    }
12    if name.contains(['/', '\\', '"', '\'', '\n', '\r']) {
13        return Err("name must not contain path separators, quotes or newlines".into());
14    }
15    let valid = name.char_indices().all(|(i, c)| {
16        (i == 0 && c.is_ascii_lowercase())
17            || (i > 0 && (c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-'))
18    });
19    if !valid {
20        return Err("name must match ^[a-z][a-z0-9_-]*$ (start with a lowercase letter)".into());
21    }
22    Ok(())
23}
24
25pub fn generate_cargo_toml(name: &str) -> String {
26    let ecat_version = env!("CARGO_PKG_VERSION");
27    format!(
28        r#"[package]
29name = "{name}"
30version = "0.1.0"
31edition = "2024"
32
33[dependencies]
34ecat = "{ecat_version}"
35ecat-transport-http = "{ecat_version}"
36ecat-middleware = "{ecat_version}"
37ecat-logging = "{ecat_version}"
38tokio = {{ version = "1", features = ["full"] }}
39tracing = "0.1"
40axum = "0.8"
41serde = {{ version = "1", features = ["derive"] }}
42serde_json = "1"
43"#
44    )
45}
46
47pub fn generate_main_rs() -> &'static str {
48    r#"use axum::{routing::get, Json, Router};
49use ecat::App;
50use ecat_middleware::{LoggingLayer, TracingLayer};
51use ecat_transport_http::HttpServer;
52use serde::Serialize;
53use tower::ServiceBuilder;
54
55#[derive(Serialize)]
56struct HealthResponse {
57    status: &'static str,
58}
59
60async fn health() -> Json<HealthResponse> {
61    Json(HealthResponse { status: "ok" })
62}
63
64#[tokio::main]
65async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
66    let middleware = ServiceBuilder::new()
67        .layer(TracingLayer::new(env!("CARGO_PKG_NAME")))
68        .layer(LoggingLayer);
69
70    let router = Router::new()
71        .route("/health", get(health))
72        .layer(middleware);
73
74    let http_srv = HttpServer::new("0.0.0.0:8000").router(router);
75
76    let mut app = App::builder()
77        .name(env!("CARGO_PKG_NAME"))
78        .version(env!("CARGO_PKG_VERSION"))
79        .server(http_srv)
80        .build()?;
81
82    app.run().await?;
83    Ok(())
84}
85"#
86}
87
88pub fn generate_proto_file() -> &'static str {
89    r#"syntax = "proto3";
90package service;
91
92service Service {
93    rpc Health(HealthRequest) returns (HealthResponse);
94}
95
96message HealthRequest {}
97message HealthResponse {
98    string status = 1;
99}
100"#
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn cargo_toml_contains_package_name() {
109        let toml = generate_cargo_toml("myapp");
110        assert!(toml.contains("name = \"myapp\""));
111        assert!(toml.contains("edition = \"2024\""));
112    }
113
114    #[test]
115    fn cargo_toml_includes_dependencies() {
116        let toml = generate_cargo_toml("test-app");
117        let ecat_version = env!("CARGO_PKG_VERSION");
118        assert!(toml.contains(&format!("ecat = \"{ecat_version}\"")));
119        assert!(toml.contains(&format!("ecat-transport-http = \"{ecat_version}\"")));
120        assert!(toml.contains("axum = \"0.8\""));
121        assert!(toml.contains("tokio"));
122    }
123
124    #[test]
125    fn cargo_toml_uses_current_ecat_version() {
126        let toml = generate_cargo_toml("test-app");
127        let ecat_version = env!("CARGO_PKG_VERSION");
128        assert!(!toml.contains("ecat = \"1.0\""));
129        assert!(toml.contains(&format!("ecat-logging = \"{ecat_version}\"")));
130    }
131
132    #[test]
133    fn main_rs_contains_health_endpoint() {
134        let src = generate_main_rs();
135        assert!(src.contains("/health"));
136        assert!(src.contains("HealthResponse"));
137        assert!(src.contains("HttpServer::new"));
138    }
139
140    #[test]
141    fn main_rs_uses_middleware() {
142        let src = generate_main_rs();
143        assert!(src.contains("TracingLayer"));
144        assert!(src.contains("LoggingLayer"));
145    }
146
147    #[test]
148    fn proto_file_has_service_definition() {
149        let proto = generate_proto_file();
150        assert!(proto.contains("service Service"));
151        assert!(proto.contains("HealthRequest"));
152        assert!(proto.contains("HealthResponse"));
153        assert!(proto.contains("syntax = \"proto3\""));
154    }
155
156    #[test]
157    fn proto_file_valid_syntax() {
158        let proto = generate_proto_file();
159        assert!(proto.starts_with("syntax = \"proto3\";"));
160        assert!(proto.contains("message HealthRequest {}"));
161    }
162
163    #[test]
164    fn cargo_toml_special_chars_in_name() {
165        let toml = generate_cargo_toml("my_app-123");
166        assert!(toml.contains("name = \"my_app-123\""));
167    }
168
169    #[test]
170    fn validate_crate_name_accepts_valid_names() {
171        assert!(validate_crate_name("myapp").is_ok());
172        assert!(validate_crate_name("my-app-123").is_ok());
173        assert!(validate_crate_name("my_app").is_ok());
174    }
175
176    #[test]
177    fn validate_crate_name_rejects_path_traversal() {
178        assert!(validate_crate_name("../../x").is_err());
179        assert!(validate_crate_name("a/b").is_err());
180        assert!(validate_crate_name("a\\b").is_err());
181    }
182
183    #[test]
184    fn validate_crate_name_rejects_injection_chars() {
185        assert!(validate_crate_name("a\"b").is_err());
186        assert!(validate_crate_name("a'b").is_err());
187        assert!(validate_crate_name("a\nb").is_err());
188        assert!(validate_crate_name("a\rb").is_err());
189    }
190
191    #[test]
192    fn validate_crate_name_rejects_bad_patterns() {
193        assert!(validate_crate_name("").is_err());
194        assert!(validate_crate_name("1app").is_err());
195        assert!(validate_crate_name("App").is_err());
196        assert!(validate_crate_name("app name").is_err());
197        assert!(validate_crate_name("app.name").is_err());
198    }
199}