pipecircuit-cli 0.0.3

Command-line architectural tools for Pipecircuit programs
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(())
    }
}