use std::{error::Error, ffi::OsString, fs, io::Write as _, path::PathBuf};
use super::{CliError, Command, utf8};
pub(super) struct NewCommand {
destination: PathBuf,
name: String,
framework: Option<PathBuf>,
template: Template,
}
#[derive(Clone, Copy)]
enum Template {
Portable,
Native,
Browser,
}
pub(super) fn parse(mut arguments: impl Iterator<Item = OsString>) -> Result<Command, CliError> {
let mut destination = None;
let mut name = None;
let mut framework = None;
let mut template = None;
while let Some(argument) = arguments.next() {
match argument.to_str() {
Some("--template") => {
let value = arguments
.next()
.ok_or_else(|| CliError::new("missing value after `--template`"))?;
let selected = match value.to_str() {
Some("portable") => Template::Portable,
Some("native") => Template::Native,
Some("browser") => Template::Browser,
_ => {
return Err(CliError::new(
"template must be `portable`, `native`, or `browser`",
));
}
};
if template.replace(selected).is_some() {
return Err(CliError::new("`--template` was provided more than once"));
}
}
Some("--name") => {
let value = arguments
.next()
.ok_or_else(|| CliError::new("missing value after `--name`"))?;
if name.replace(utf8(value, "project name")?).is_some() {
return Err(CliError::new("`--name` was provided more than once"));
}
}
Some("--framework-path") => {
let value = arguments
.next()
.ok_or_else(|| CliError::new("missing path after `--framework-path`"))?;
if framework.replace(PathBuf::from(value)).is_some() {
return Err(CliError::new(
"`--framework-path` was provided more than once",
));
}
}
Some("-h" | "--help") => return Ok(Command::Help),
Some(flag) if flag.starts_with('-') => {
return Err(CliError::new(format!("unknown option `{flag}`")));
}
_ => {
if destination.replace(PathBuf::from(argument)).is_some() {
return Err(CliError::new("more than one destination was provided"));
}
}
}
}
let destination =
destination.ok_or_else(|| CliError::new("missing destination for `pipec new`"))?;
let name = match name {
Some(name) => name,
None => destination
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| {
CliError::new("destination needs a UTF-8 project name, or use `--name`")
})?
.to_owned(),
};
validate_name(&name)?;
Ok(Command::New(NewCommand {
destination,
name,
framework,
template: template.unwrap_or(Template::Portable),
}))
}
fn validate_name(name: &str) -> Result<(), CliError> {
let starts_with_letter = name
.bytes()
.next()
.is_some_and(|first| first.is_ascii_lowercase());
let portable = name.bytes().all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-' || byte == b'_'
});
let reserved = matches!(name, "con" | "prn" | "aux" | "nul")
|| (name.len() == 4
&& (name.starts_with("com") || name.starts_with("lpt"))
&& name.ends_with(|ch: char| ch.is_ascii_digit()));
if !starts_with_letter || !portable || reserved || name.len() > 64 {
return Err(CliError::new(
"project name must start with a lowercase ASCII letter and contain only lowercase letters, digits, hyphens or underscores (at most 64 characters, excluding reserved device names)",
));
}
Ok(())
}
fn toml_string(value: &str) -> Result<String, CliError> {
let mut escaped = String::from("\"");
for character in value.chars() {
match character {
'\\' => escaped.push_str("\\\\"),
'"' => escaped.push_str("\\\""),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
'\t' => escaped.push_str("\\t"),
character if character.is_control() => {
return Err(CliError::new(
"framework path contains an unsupported control character",
));
}
character => escaped.push(character),
}
}
escaped.push('"');
Ok(escaped)
}
const FRAMEWORK_VERSION: &str = "0.0.21";
const MIO_VERSION: &str = "0.0.15";
const BROWSER_VERSION: &str = "0.0.11";
const FILES: &[(&str, &str)] = &[
(
".cargo/config.toml",
include_str!("../template/.cargo/config.toml"),
),
(".gitignore", include_str!("../template/.gitignore")),
("Cargo.toml", include_str!("../template/Cargo.toml.in")),
(
"apps/__PROJECT__/Cargo.toml",
include_str!("../template/app/Cargo.toml.in"),
),
(
"apps/__PROJECT__/pipes/Cargo.toml",
include_str!("../template/app/pipes/Cargo.toml.in"),
),
(
"apps/__PROJECT__/wiring/Cargo.toml",
include_str!("../template/app/wiring/Cargo.toml.in"),
),
(
"core/Cargo.toml",
include_str!("../template/core/Cargo.toml.in"),
),
(
"core/src/domain_primitives/mod.rs",
include_str!("../template/core/src/domain_primitives/mod.rs"),
),
(
"core/src/lib.rs",
include_str!("../template/core/src/lib.rs"),
),
(
"apps/__PROJECT__/pipes/src/lib.rs",
include_str!("../template/app/pipes/src/lib.rs"),
),
(
"core/src/state_machines/latch/architecture.rs",
include_str!("../template/core/src/state_machines/latch/architecture.rs"),
),
(
"core/src/state_machines/latch/behavior.rs",
include_str!("../template/core/src/state_machines/latch/behavior.rs"),
),
(
"core/src/state_machines/latch/mod.rs",
include_str!("../template/core/src/state_machines/latch/mod.rs"),
),
(
"core/src/state_machines/latch/tests.rs",
include_str!("../template/core/src/state_machines/latch/tests.rs"),
),
(
"core/src/state_machines/mod.rs",
include_str!("../template/core/src/state_machines/mod.rs"),
),
(
"apps/__PROJECT__/switchboard/Cargo.toml",
include_str!("../template/app/switchboard/Cargo.toml.in"),
),
(
"apps/__PROJECT__/switchboard/src/lib.rs",
include_str!("../template/app/switchboard/src/lib.rs"),
),
(
"apps/__PROJECT__/src/main.rs",
include_str!("../template/app/src/main.rs"),
),
(
"apps/__PROJECT__/wiring/src/lib.rs",
include_str!("../template/app/wiring/src/lib.rs"),
),
];
const BROWSER_FILES: &[(&str, &str)] = &[
(
"apps/__PROJECT__/wiring/web/tools/browser-test.mjs",
include_str!("../browser/web/tools/browser-test.mjs"),
),
(
"apps/__PROJECT__/wiring/tools/generate_worker.rs",
include_str!("../browser/tools/generate_worker.rs"),
),
(
"apps/__PROJECT__/wiring/web/package.json",
include_str!("../browser/web/package.json"),
),
(
"apps/__PROJECT__/wiring/web/tsconfig.json",
include_str!("../browser/web/tsconfig.json"),
),
(
"apps/__PROJECT__/wiring/web/index.html",
include_str!("../browser/web/index.html"),
),
(
"apps/__PROJECT__/wiring/web/src/connection.ts",
include_str!("../browser/web/src/connection.ts"),
),
(
"apps/__PROJECT__/wiring/web/src/main.ts",
include_str!("../browser/web/src/main.ts"),
),
(
"apps/__PROJECT__/wiring/web/tools/build.mjs",
include_str!("../browser/web/tools/build.mjs"),
),
(
"apps/__PROJECT__/wiring/web/worker.config.mjs",
include_str!("../browser/web/worker.config.mjs"),
),
(
"apps/__PROJECT__/wiring/web/vite.config.mjs",
include_str!("../browser/web/vite.config.mjs"),
),
];
pub(super) fn create(command: NewCommand) -> Result<String, Box<dyn Error>> {
validate_name(&command.name)?;
let native_dependency = match (command.template, &command.framework) {
(Template::Portable | Template::Browser, _) => String::new(),
(Template::Native, None) => format!(r#"pipecircuit-mio = "={MIO_VERSION}""#),
(Template::Native, Some(path)) => {
let path = path.canonicalize()?;
let root = path
.parent()
.ok_or_else(|| CliError::new("framework crate has no parent directory"))?;
let mio = root.join("libraries/mio").canonicalize()?;
if !mio.join("Cargo.toml").is_file() {
return Err(CliError::new(
"native template needs libraries/mio in the framework checkout",
)
.into());
}
let mio = mio
.to_str()
.ok_or_else(|| CliError::new("Mio path must be UTF-8"))?;
format!(
r#"pipecircuit-mio = {{ path = {}, version = "={MIO_VERSION}" }}"#,
toml_string(mio)?
)
}
};
let browser_dependency = match (command.template, &command.framework) {
(Template::Browser, Some(path)) => {
let path = path.canonicalize()?;
let root = path
.parent()
.ok_or_else(|| CliError::new("framework crate has no parent directory"))?;
let browser = root.join("libraries/browser").canonicalize()?;
if !browser.join("Cargo.toml").is_file() {
return Err(CliError::new(
"browser template needs libraries/browser in the framework checkout",
)
.into());
}
let browser = browser
.to_str()
.ok_or_else(|| CliError::new("browser path must be UTF-8"))?;
format!(
r#"{{ path = {}, version = "={BROWSER_VERSION}" }}"#,
toml_string(browser)?
)
}
(Template::Browser, None) => format!(r#"{{ version = "={BROWSER_VERSION}" }}"#),
(Template::Portable | Template::Native, _) => String::new(),
};
let bindings_dependency = match (command.template, &command.framework) {
(Template::Browser, Some(path)) => {
let path = path.canonicalize()?;
let root = path.parent().ok_or_else(|| CliError::new("framework crate has no parent directory"))?;
let bindings = root.join("tools/vendor/postcard-bindgen/facade").canonicalize()?;
if !bindings.join("Cargo.toml").is_file() {
return Err(CliError::new("browser template needs the binding generator in the framework checkout").into());
}
let bindings = bindings.to_str().ok_or_else(|| CliError::new("bindings path must be UTF-8"))?;
format!(r#"{{ package = "pipecircuit-postcard-bindgen", path = {}, version = "=0.0.1", default-features = false, features = ["generating"] }}"#, toml_string(bindings)?)
}
(Template::Browser | Template::Portable | Template::Native, _) => {
r#"{ package = "pipecircuit-postcard-bindgen", version = "=0.0.1", default-features = false, features = ["generating"] }"#.to_owned()
}
};
let dependency = match command.framework {
Some(path) => {
let path = path.canonicalize()?;
if !path.join("Cargo.toml").is_file() {
return Err(CliError::new(
"framework path must contain the pipecircuit crate's Cargo.toml",
)
.into());
}
let path = path
.to_str()
.ok_or_else(|| CliError::new("framework path must be UTF-8"))?;
format!(
"{{ path = {}, version = \"={FRAMEWORK_VERSION}\", default-features = false }}",
toml_string(path)?
)
}
None => format!("{{ version = \"={FRAMEWORK_VERSION}\", default-features = false }}"),
};
fs::create_dir(&command.destination)?;
let extra = match command.template {
Template::Browser => BROWSER_FILES,
Template::Portable | Template::Native => &[],
};
for (relative, template) in FILES.iter().chain(extra) {
if matches!(command.template, Template::Browser)
&& matches!(*relative, "apps/__PROJECT__/src/main.rs")
{
continue;
}
let path = command
.destination
.join(relative.replace("__PROJECT__", &command.name));
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let template = match (command.template, *relative) {
(Template::Browser, "apps/__PROJECT__/wiring/src/lib.rs") => {
include_str!("../browser/src/lib.rs")
}
(Template::Native, "apps/__PROJECT__/wiring/src/lib.rs") => {
include_str!("../native/wiring.rs")
}
(Template::Browser, "apps/__PROJECT__/wiring/Cargo.toml") => {
include_str!("../browser/Cargo.toml.in")
}
(Template::Browser, "apps/__PROJECT__/Cargo.toml") => {
include_str!("../browser/app.Cargo.toml.in")
}
(Template::Portable | Template::Native | Template::Browser, _) => template,
};
let contents = template
.replace("__CRATE__", &command.name.replace('-', "_"))
.replace("__PROJECT__", &command.name)
.replace("__FRAMEWORK_DEPENDENCY__", &dependency)
.replace("__NATIVE_DEPENDENCY__", &native_dependency)
.replace("__BROWSER_DEPENDENCY__", &browser_dependency)
.replace("__BINDINGS_DEPENDENCY__", &bindings_dependency);
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)?;
file.write_all(contents.as_bytes())?;
}
if matches!(command.template, Template::Browser) {
let path = command
.destination
.join(format!("apps/{}/src", command.name));
fs::create_dir_all(&path)?;
fs::write(
path.join("lib.rs"),
format!(
"pub use {}_wiring::ActivationWorker;\n",
command.name.replace('-', "_")
),
)?;
}
let entry = match command.template {
Template::Browser => "a Rust worker in src/lib.rs and a page in web/",
Template::Portable | Template::Native => "a runnable app/src/main.rs",
};
Ok(format!(
"Created {} with core and app-local pipes, switchboard, wiring, and {entry}.\n",
command.destination.display()
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generation_preserves_existing_destinations_and_rejects_invalid_names()
-> Result<(), Box<dyn Error>> {
let scratch = tempfile::tempdir()?;
let destination = scratch.path().join("existing");
fs::create_dir(&destination)?;
fs::write(destination.join("keep"), "user work")?;
assert!(
create(NewCommand {
destination: destination.clone(),
name: "safe".to_owned(),
framework: None,
template: Template::Portable,
})
.is_err()
);
assert_eq!(fs::read_to_string(destination.join("keep"))?, "user work");
assert!(!destination.join("Cargo.toml").exists());
for name in ["", "../escape", "name\"injection", "Caps", "con", "com1"] {
assert!(validate_name(name).is_err());
}
Ok(())
}
#[test]
fn generated_libraries_share_strictness_and_only_depend_inward() -> Result<(), Box<dyn Error>> {
let scratch = tempfile::tempdir()?;
let destination = scratch.path().join("conversation");
create(NewCommand {
destination: destination.clone(),
name: "conversation".to_owned(),
framework: None,
template: Template::Portable,
})?;
let core = fs::read_to_string(destination.join("core/Cargo.toml"))?;
let switchboards =
fs::read_to_string(destination.join("apps/conversation/switchboard/Cargo.toml"))?;
assert!(core.contains("workspace = true"));
assert!(!core.contains("conversation-switchboard"));
assert!(switchboards.contains("conversation-pipes.workspace = true"));
for path in [
"core/src/lib.rs",
"apps/conversation/switchboard/src/lib.rs",
] {
assert!(fs::read_to_string(destination.join(path))?.starts_with("#![no_std]"));
}
assert!(!destination.join("programs").exists());
assert!(!destination.join("core/src/circuits").exists());
for file in ["architecture.rs", "behavior.rs", "tests.rs"] {
assert!(
destination
.join("core/src/state_machines/latch")
.join(file)
.is_file()
);
}
assert!(
destination
.join("apps/conversation/pipes/src/lib.rs")
.is_file()
);
assert!(
destination
.join("apps/conversation/wiring/src/lib.rs")
.is_file()
);
Ok(())
}
#[test]
fn help_and_unknown_options_do_not_become_destinations() -> Result<(), Box<dyn Error>> {
for flag in ["-h", "--help"] {
assert!(matches!(
parse([flag].into_iter().map(OsString::from))?,
Command::Help
));
}
let result = parse(
["--unknown", "--name", "application"]
.into_iter()
.map(OsString::from),
);
match result {
Err(error) => assert_eq!(error.to_string(), "unknown option `--unknown`"),
Ok(
Command::Help
| Command::Version
| Command::New(_)
| Command::Diagram(_)
| Command::Arch(_),
) => {
return Err("unknown option accepted".into());
}
}
Ok(())
}
#[test]
fn native_placement_is_an_explicit_creation_choice() -> Result<(), Box<dyn Error>> {
let Command::New(default) = parse(["application"].into_iter().map(OsString::from))? else {
return Err("new command absent".into());
};
assert!(matches!(default.template, Template::Portable));
let Command::New(native) = parse(
["application", "--template", "native"]
.into_iter()
.map(OsString::from),
)?
else {
return Err("new command absent".into());
};
assert!(matches!(native.template, Template::Native));
let Command::New(portable) = parse(
["application", "--template", "portable"]
.into_iter()
.map(OsString::from),
)?
else {
return Err("new command absent".into());
};
assert!(matches!(portable.template, Template::Portable));
Ok(())
}
#[test]
fn browser_placement_keeps_semantics_and_has_no_native_entry() -> Result<(), Box<dyn Error>> {
let scratch = tempfile::tempdir()?;
let portable = scratch.path().join("portable");
let browser = scratch.path().join("browser");
let Command::New(command) = parse(
[
browser.clone().into_os_string(),
OsString::from("--template"),
OsString::from("browser"),
OsString::from("--name"),
OsString::from("shared-model"),
]
.into_iter(),
)?
else {
return Err("new command absent".into());
};
assert!(matches!(command.template, Template::Browser));
create(command)?;
create(NewCommand {
destination: portable.clone(),
name: "shared-model".to_owned(),
framework: None,
template: Template::Portable,
})?;
for (path, _) in FILES {
if path.starts_with("core/") {
assert_eq!(
fs::read(portable.join(path.replace("__PROJECT__", "shared-model")))?,
fs::read(browser.join(path.replace("__PROJECT__", "shared-model")))?,
"{path}"
);
}
}
assert!(!browser.join("apps/shared-model/src/main.rs").exists());
assert!(browser.join("apps/shared-model/wiring/src/lib.rs").exists());
let manifest = fs::read_to_string(browser.join("apps/shared-model/wiring/Cargo.toml"))?;
assert!(manifest.contains(BROWSER_VERSION));
assert!(!manifest.contains("pipecircuit-mio"));
for (path, _) in BROWSER_FILES {
let contents =
fs::read_to_string(browser.join(path.replace("__PROJECT__", "shared-model")))?;
assert!(!contents.contains("__PROJECT__"), "{path}");
assert!(!contents.contains("__CRATE__"), "{path}");
}
Ok(())
}
#[test]
fn placement_changes_only_dependencies_and_wiring() -> Result<(), Box<dyn Error>> {
let scratch = tempfile::tempdir()?;
let portable = scratch.path().join("portable");
let native = scratch.path().join("native");
for (destination, template) in
[(&portable, Template::Portable), (&native, Template::Native)]
{
create(NewCommand {
destination: destination.clone(),
name: "shared-model".to_owned(),
framework: None,
template,
})?;
}
for (path, _) in FILES {
if *path != "apps/__PROJECT__/wiring/Cargo.toml"
&& *path != "apps/__PROJECT__/wiring/src/lib.rs"
{
assert_eq!(
fs::read(portable.join(path.replace("__PROJECT__", "shared-model")))?,
fs::read(native.join(path.replace("__PROJECT__", "shared-model")))?,
"{path}"
);
}
}
assert!(
!fs::read_to_string(portable.join("apps/shared-model/wiring/Cargo.toml"))?
.contains("pipecircuit-mio")
);
assert!(
fs::read_to_string(native.join("apps/shared-model/wiring/Cargo.toml"))?
.contains(MIO_VERSION)
);
assert_ne!(
fs::read(portable.join("apps/shared-model/wiring/src/lib.rs"))?,
fs::read(native.join("apps/shared-model/wiring/src/lib.rs"))?
);
Ok(())
}
#[test]
fn unavailable_host_checkout_leaves_no_partial_project() -> Result<(), Box<dyn Error>> {
let scratch = tempfile::tempdir()?;
let framework = scratch.path().join("pipecircuit");
fs::create_dir(&framework)?;
fs::write(framework.join("Cargo.toml"), "[package]\n")?;
let destination = scratch.path().join("application");
for template in [Template::Native, Template::Browser] {
assert!(
create(NewCommand {
destination: destination.clone(),
name: "application".to_owned(),
framework: Some(framework.clone()),
template,
})
.is_err()
);
assert!(!destination.exists());
}
Ok(())
}
#[test]
fn new_arguments_are_unambiguous_and_paths_are_encoded_as_toml_strings()
-> Result<(), Box<dyn Error>> {
for values in [
vec!["new"],
vec!["new", "a", "b"],
vec!["new", "a", "--name"],
vec!["new", "a", "--framework-path"],
vec!["new", "a", "--template"],
vec!["new", "a", "--template", "unknown"],
vec!["new", "a", "--template", "native", "--template", "portable"],
vec!["new", "a", "--name", "b", "--name", "c"],
] {
assert!(super::super::parse_arguments(values.into_iter().map(OsString::from)).is_err());
}
let Command::New(command) = parse(
[
"somewhere",
"--name",
"conversation",
"--framework-path",
"../pipecircuit",
]
.into_iter()
.map(OsString::from),
)?
else {
return Err("new command absent".into());
};
assert_eq!(command.name, "conversation");
assert_eq!(command.framework, Some(PathBuf::from("../pipecircuit")));
assert_eq!(toml_string("a\\b\"c\nd")?, "\"a\\\\b\\\"c\\nd\"");
Ok(())
}
}