rspyts-cli 1.0.0

Compiler and build orchestrator for rspyts
Documentation
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};
use serde::Serialize;

use crate::output::write;

#[derive(Debug, Serialize)]
pub(super) struct InitReport {
    status: &'static str,
    project: PathBuf,
}

pub(super) fn create(path: &Path) -> Result<InitReport> {
    if path.exists() {
        bail!(
            "{} already exists; rspyts will not overwrite it",
            path.display()
        );
    }
    let name = path
        .file_name()
        .and_then(|value| value.to_str())
        .context("the project path must end with a UTF-8 name")?;
    validate_name(name)?;
    let rust_name = name.replace('-', "_");
    let api_package = format!("{name}-api");
    let api_crate = format!("{rust_name}_api");
    let client_package = format!("{rust_name}_client");
    let parent = path
        .parent()
        .filter(|value| !value.as_os_str().is_empty())
        .unwrap_or_else(|| Path::new("."));
    fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?;
    let temporary = tempfile::Builder::new()
        .prefix(".rspyts-init-")
        .tempdir_in(parent)?;
    let root = temporary.path();

    write(&root.join("Cargo.toml"), ROOT_CARGO)?;
    write(&root.join(".gitignore"), GITIGNORE)?;
    write(
        &root.join("crates/api/Cargo.toml"),
        &API_CARGO.replace("__API_PACKAGE__", &api_package),
    )?;
    write(&root.join("crates/api/src/lib.rs"), API_LIB)?;
    write(
        &root.join("crates/bindings/Cargo.toml"),
        &BINDINGS_CARGO
            .replace("__PROJECT__", name)
            .replace("__API_PACKAGE__", &api_package),
    )?;
    write(
        &root.join("crates/bindings/src/lib.rs"),
        &BINDINGS_LIB.replace("__API_CRATE__", &api_crate),
    )?;
    write(
        &root.join("clients/python/pyproject.toml"),
        &PYPROJECT
            .replace("__PROJECT__", name)
            .replace("__PYTHON_PACKAGE__", &rust_name)
            .replace("__CLIENT_PACKAGE__", &client_package),
    )?;
    write(
        &root.join(format!("clients/python/{client_package}/__init__.py")),
        &PYTHON_CLIENT.replace("__PYTHON_PACKAGE__", &rust_name),
    )?;
    write(
        &root.join("clients/python/tests/test_client.py"),
        &PYTHON_TEST.replace("__CLIENT_PACKAGE__", &client_package),
    )?;
    write(
        &root.join("clients/typescript/package.json"),
        &TYPESCRIPT_PACKAGE.replace("__PROJECT__", name),
    )?;
    write(
        &root.join("clients/typescript/src/index.ts"),
        &TYPESCRIPT_CLIENT.replace("__PROJECT__", name),
    )?;
    write(
        &root.join("clients/typescript/tsconfig.json"),
        TYPESCRIPT_CONFIG,
    )?;

    fs::rename(root, path)
        .with_context(|| format!("failed to create project at {}", path.display()))?;
    Ok(InitReport {
        status: "ok",
        project: path.to_path_buf(),
    })
}

fn validate_name(name: &str) -> Result<()> {
    let mut characters = name.chars();
    let starts_correctly = characters
        .next()
        .is_some_and(|character| character.is_ascii_lowercase());
    if !starts_correctly
        || !characters.all(|character| {
            character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'
        })
        || name.ends_with('-')
        || name.contains("--")
    {
        bail!("project name `{name}` must use lower-case letters, numbers, and single hyphens");
    }
    Ok(())
}

const ROOT_CARGO: &str = r#"[workspace]
resolver = "3"
members = ["crates/api", "crates/bindings"]
default-members = ["crates/bindings"]

[workspace.package]
version = "0.1.0"
edition = "2024"
rust-version = "1.88"

[workspace.dependencies]
rspyts = "1"
serde = { version = "1", features = ["derive"] }
"#;

const GITIGNORE: &str = r"/target/
**/dist/
**/.venv/
**/__pycache__/
**/.pytest_cache/
**/node_modules/
**/build/
";

const API_CARGO: &str = r#"[package]
name = "__API_PACKAGE__"
version.workspace = true
edition.workspace = true
rust-version.workspace = true

[dependencies]
rspyts.workspace = true
serde.workspace = true
"#;

const API_LIB: &str = r#"use rspyts::Model;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, Model)]
pub struct Greeting {
    pub message: String,
}

#[rspyts::export]
pub fn greet(name: String) -> Greeting {
    Greeting {
        message: format!("Hello, {name}!"),
    }
}
"#;

const BINDINGS_CARGO: &str = r#"[package]
name = "__PROJECT__"
version.workspace = true
edition.workspace = true
rust-version.workspace = true

[lib]
crate-type = ["cdylib"]

[dependencies]
__API_PACKAGE__ = { path = "../api" }
rspyts.workspace = true
"#;

const BINDINGS_LIB: &str = "rspyts::application!(__API_CRATE__);\n";

const PYPROJECT: &str = r#"[project]
name = "__PROJECT__-client"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["__PYTHON_PACKAGE__"]

[tool.uv.sources]
__PYTHON_PACKAGE__ = { path = "../../crates/bindings/dist/python" }

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["__CLIENT_PACKAGE__"]

[dependency-groups]
dev = ["pytest>=8,<9"]
"#;

const PYTHON_CLIENT: &str = r#"from __PYTHON_PACKAGE__ import Greeting, greet

__all__ = ["Greeting", "greet"]
"#;

const PYTHON_TEST: &str = r#"from __CLIENT_PACKAGE__ import greet


def test_greeting() -> None:
    assert greet("World").message == "Hello, World!"
"#;

const TYPESCRIPT_PACKAGE: &str = r#"{
  "name": "__PROJECT__-client",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "tsc",
    "check": "tsc --noEmit",
    "start": "node build/index.js"
  },
  "dependencies": {
    "__PROJECT__": "file:../../crates/bindings/dist/typescript"
  },
  "devDependencies": {
    "typescript": "^5.9.0"
  }
}
"#;

const TYPESCRIPT_CLIENT: &str = r#"import { greet } from "__PROJECT__";

console.log(greet("World").message);
"#;

const TYPESCRIPT_CONFIG: &str = r#"{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "build",
    "rootDir": "src",
    "strict": true,
    "target": "ES2022"
  },
  "include": ["src"]
}
"#;

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn creates_the_complete_project() {
        let directory = tempfile::tempdir().unwrap();
        let project = directory.path().join("hello-world");

        create(&project).unwrap();

        for path in [
            "Cargo.toml",
            "crates/api/src/lib.rs",
            "crates/bindings/src/lib.rs",
            "clients/python/hello_world_client/__init__.py",
            "clients/python/tests/test_client.py",
            "clients/typescript/src/index.ts",
        ] {
            assert!(project.join(path).is_file(), "missing {path}");
        }
        let cargo = fs::read_to_string(project.join("Cargo.toml")).unwrap();
        assert!(cargo.contains("rspyts = \"1\""));
        assert!(create(&project).is_err());
    }

    #[test]
    fn rejects_invalid_names() {
        let directory = tempfile::tempdir().unwrap();
        assert!(create(&directory.path().join("Hello_world")).is_err());
    }
}