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>,
}
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;
while let Some(argument) = arguments.next() {
match argument.to_str() {
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,
}))
}
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.19";
const FILES: &[(&str, &str)] = &[
("Cargo.toml", include_str!("../template/Cargo.toml.in")),
(".gitignore", include_str!("../template/.gitignore")),
(
".cargo/config.toml",
include_str!("../template/.cargo/config.toml"),
),
(
"core/Cargo.toml",
include_str!("../template/core/Cargo.toml.in"),
),
(
"core/src/lib.rs",
include_str!("../template/core/src/lib.rs"),
),
(
"core/src/domain_primitives/mod.rs",
include_str!("../template/core/src/domain_primitives/mod.rs"),
),
(
"core/src/state_machines/mod.rs",
include_str!("../template/core/src/state_machines/mod.rs"),
),
(
"programs/Cargo.toml",
include_str!("../template/programs/Cargo.toml.in"),
),
(
"programs/src/lib.rs",
include_str!("../template/programs/src/lib.rs"),
),
(
"programs/src/pipes/mod.rs",
include_str!("../template/programs/src/pipes/mod.rs"),
),
(
"programs/src/switchboard/mod.rs",
include_str!("../template/programs/src/switchboard/mod.rs"),
),
("src/main.rs", include_str!("../template/src/main.rs")),
(
"src/wiring/mod.rs",
include_str!("../template/src/wiring/mod.rs"),
),
(
"benches/.gitkeep",
include_str!("../template/benches/.gitkeep"),
),
(
"core/src/domain_primitives/proofs.rs",
include_str!("../template/core/src/domain_primitives/proofs.rs"),
),
(
"core/src/domain_primitives/tests.rs",
include_str!("../template/core/src/domain_primitives/tests.rs"),
),
(
"core/src/state_machines/proofs.rs",
include_str!("../template/core/src/state_machines/proofs.rs"),
),
(
"core/src/state_machines/tests.rs",
include_str!("../template/core/src/state_machines/tests.rs"),
),
(
"fuzz/.gitignore",
include_str!("../template/fuzz/.gitignore"),
),
(
"fuzz/Cargo.toml",
include_str!("../template/fuzz/Cargo.toml.in"),
),
(
"fuzz/fuzz_targets/.gitkeep",
include_str!("../template/fuzz/fuzz_targets/.gitkeep"),
),
(
"fuzz/src/lib.rs",
include_str!("../template/fuzz/src/lib.rs"),
),
("tests/.gitkeep", include_str!("../template/tests/.gitkeep")),
(
"tests/ui/.gitkeep",
include_str!("../template/tests/ui/.gitkeep"),
),
];
pub(super) fn create(command: NewCommand) -> Result<String, Box<dyn Error>> {
validate_name(&command.name)?;
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)?;
for (relative, template) in FILES {
let path = command.destination.join(relative);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let contents = template
.replace("__PROJECT__", &command.name)
.replace("__FRAMEWORK_DEPENDENCY__", &dependency);
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)?;
file.write_all(contents.as_bytes())?;
}
Ok(format!(
"Created {} with core, programs, and src/main.rs.\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
})
.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,
})?;
let core = fs::read_to_string(destination.join("core/Cargo.toml"))?;
let program = fs::read_to_string(destination.join("programs/Cargo.toml"))?;
assert!(core.contains("workspace = true"));
assert!(!core.contains("conversation-program"));
assert!(program.contains("conversation-core.workspace = true"));
for path in ["core/src/lib.rs", "programs/src/lib.rs"] {
assert!(fs::read_to_string(destination.join(path))?.starts_with("#![no_std]"));
}
assert!(!destination.join("core/src/circuits").exists());
assert!(destination.join("src/wiring/mod.rs").is_file());
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", "--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(())
}
}