From 191eec0b3499c6b6ea1af9ed0848e04a712f93f7 Mon Sep 17 00:00:00 2001 From: omnitrix Date: Sun, 7 Dec 2025 23:59:42 +0000 Subject: [PATCH] refactor(xtask): migrate project renaming logic from shell script to rust implementation --- README.md | 11 +- rename-project.ps1 | 133 ------------------ rename-project.sh | 144 ------------------- xtask/src/main.rs | 343 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 351 insertions(+), 280 deletions(-) delete mode 100644 rename-project.ps1 delete mode 100755 rename-project.sh diff --git a/README.md b/README.md index 44afbc1..987256e 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,14 @@ Use this repository as a GitHub template to quickly start a new Rust project. ## Getting Started 1. Create a new repository using this template -2. Clone your repository and run the rename script: - - **Linux/macOS:** `./rename-project.sh` - - **Windows:** `.\rename-project.ps1` +2. Clone your repository and run the bootstrap command: + ```bash + cargo xtask bootstrap + ``` + Or with arguments: + ```bash + cargo xtask bootstrap --project-name my-project --github-account my-username + ``` 3. Follow the prompts, review changes, and commit 4. Start building your project! diff --git a/rename-project.ps1 b/rename-project.ps1 deleted file mode 100644 index cb42cfc..0000000 --- a/rename-project.ps1 +++ /dev/null @@ -1,133 +0,0 @@ -# Copyright 2025 FastLabs Developers -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -$ErrorActionPreference = "Stop" - -Write-Host "========================================" -ForegroundColor Blue -Write-Host " Template Project Batch Renamer " -ForegroundColor Blue -Write-Host "========================================" -ForegroundColor Blue -Write-Host "" - -if (-not (Test-Path "Cargo.toml") -or -not (Test-Path "template" -PathType Container)) { - Write-Host "ERROR: This script must be run from the project root directory" -ForegroundColor Red - exit 1 -} - -Write-Host "Please provide the following information:" -ForegroundColor Yellow -Write-Host "" - -$ProjectName = Read-Host "New project name (e.g., my-awesome-project)" -$GitHubUser = Read-Host "GitHub username/org (e.g., myname)" - -if ([string]::IsNullOrWhiteSpace($ProjectName)) { - Write-Host "ERROR: Project name is required" -ForegroundColor Red - exit 1 -} - -if ([string]::IsNullOrWhiteSpace($GitHubUser)) { - Write-Host "ERROR: GitHub username is required" -ForegroundColor Red - exit 1 -} - -# Validate project name format (Rust package naming convention) -if ($ProjectName -notmatch '^[a-z][a-z0-9_-]*$') { - Write-Host "ERROR: Project name must start with a lowercase letter and contain only lowercase letters, numbers, hyphens, and underscores" -ForegroundColor Red - exit 1 -} - -Write-Host "" -Write-Host "Summary:" -ForegroundColor Blue -Write-Host " Project name: $ProjectName" -ForegroundColor Green -Write-Host " GitHub repo: $GitHubUser/$ProjectName" -ForegroundColor Green -Write-Host " Crates.io URL: https://crates.io/crates/$ProjectName" -ForegroundColor Green -Write-Host "" - -$Confirm = Read-Host "Continue with renaming? (y/N)" -$ConfirmLower = $Confirm.ToLower() -if ($ConfirmLower -ne "y" -and $ConfirmLower -ne "yes") { - Write-Host "Cancelled." -ForegroundColor Yellow - exit 0 -} - -Write-Host "" -Write-Host "Starting batch rename..." -ForegroundColor Blue -Write-Host "" - -function Update-FileContent { - param( - [string]$FilePath, - [string]$OldValue, - [string]$NewValue - ) - - $content = Get-Content $FilePath -Raw - $content = $content -replace [regex]::Escape($OldValue), $NewValue - Set-Content $FilePath -Value $content -NoNewline -} - -# 1. Update root Cargo.toml -Write-Host "[OK] Updating Cargo.toml..." -ForegroundColor Green -Update-FileContent "Cargo.toml" "https://github.com/fast/template" "https://github.com/$GitHubUser/$ProjectName" -Update-FileContent "Cargo.toml" '"template"' "`"$ProjectName`"" - -# 2. Update template/Cargo.toml -Write-Host "[OK] Updating template/Cargo.toml..." -ForegroundColor Green -Update-FileContent "template/Cargo.toml" 'name = "template"' "name = `"$ProjectName`"" - -# 3. Update README.md -Write-Host "[OK] Updating README.md..." -ForegroundColor Green -Update-FileContent "README.md" "crates.io/crates/template" "crates.io/crates/$ProjectName" -Update-FileContent "README.md" "img.shields.io/crates/v/template.svg" "img.shields.io/crates/v/$ProjectName.svg" -Update-FileContent "README.md" "img.shields.io/crates/l/template" "img.shields.io/crates/l/$ProjectName" -Update-FileContent "README.md" "github.com/fast/template" "github.com/$GitHubUser/$ProjectName" -Update-FileContent "README.md" "docs.rs/template" "docs.rs/$ProjectName" - -# 4. Update .github/semantic.yml -Write-Host "[OK] Updating .github/semantic.yml..." -ForegroundColor Green -Update-FileContent ".github/semantic.yml" "github.com/fast/template" "github.com/$GitHubUser/$ProjectName" - -# 5. Rename template directory -Write-Host "[OK] Renaming template/ directory to $ProjectName/..." -ForegroundColor Green -if (Test-Path "template" -PathType Container) { - Rename-Item "template" $ProjectName -} - -# 6. Update Cargo.lock -Write-Host "[OK] Updating Cargo.lock..." -ForegroundColor Green -Update-FileContent "Cargo.lock" 'name = "template"' "name = `"$ProjectName`"" - -Write-Host "" -Write-Host "========================================" -ForegroundColor Green -Write-Host " SUCCESS: Renaming completed! " -ForegroundColor Green -Write-Host "========================================" -ForegroundColor Green -Write-Host "" -Write-Host "Next steps:" -ForegroundColor Blue -Write-Host "" -Write-Host " 1. Review the changes:" -Write-Host " git diff" -ForegroundColor Yellow -Write-Host "" -Write-Host " 2. Delete the rename scripts (no longer needed):" -Write-Host " Remove-Item rename-project.sh, rename-project.ps1" -ForegroundColor Yellow -Write-Host "" -Write-Host " 3. Update the project description in README.md" -Write-Host "" -Write-Host " 4. Commit your changes:" -Write-Host " git add ." -ForegroundColor Yellow -Write-Host " git commit -m `"chore: initialize project as $ProjectName`"" -ForegroundColor Yellow -Write-Host "" -Write-Host " 5. Push to GitHub:" -Write-Host " git push" -ForegroundColor Yellow -Write-Host "" -Write-Host "Happy coding!" -ForegroundColor Green - diff --git a/rename-project.sh b/rename-project.sh deleted file mode 100755 index efcaefb..0000000 --- a/rename-project.sh +++ /dev/null @@ -1,144 +0,0 @@ -#!/usr/bin/env bash -# Copyright 2025 FastLabs Developers -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -set -euo pipefail - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -echo -e "${BLUE}========================================${NC}" -echo -e "${BLUE} Template Project Batch Renamer ${NC}" -echo -e "${BLUE}========================================${NC}" -echo "" - -if [[ ! -f "Cargo.toml" ]] || [[ ! -d "template" ]]; then - echo -e "${RED}ERROR: This script must be run from the project root directory${NC}" - exit 1 -fi - -echo -e "${YELLOW}Please provide the following information:${NC}" -echo "" - -read -p "New project name (e.g., my-awesome-project): " PROJECT_NAME -read -p "GitHub username/org (e.g., myname): " GITHUB_USER - -if [[ -z "$PROJECT_NAME" ]]; then - echo -e "${RED}ERROR: Project name is required${NC}" - exit 1 -fi - -if [[ -z "$GITHUB_USER" ]]; then - echo -e "${RED}ERROR: GitHub username is required${NC}" - exit 1 -fi - -# Validate project name format (Rust package naming convention) -if [[ ! "$PROJECT_NAME" =~ ^[a-z][a-z0-9_-]*$ ]]; then - echo -e "${RED}ERROR: Project name must start with a lowercase letter and contain only lowercase letters, numbers, hyphens, and underscores${NC}" - exit 1 -fi - -echo "" -echo -e "${BLUE}Summary:${NC}" -echo -e " Project name: ${GREEN}$PROJECT_NAME${NC}" -echo -e " GitHub repo: ${GREEN}$GITHUB_USER/$PROJECT_NAME${NC}" -echo -e " Crates.io URL: ${GREEN}https://crates.io/crates/$PROJECT_NAME${NC}" -echo "" -read -p "Continue with renaming? (y/N): " CONFIRM - -CONFIRM_LOWER=$(echo "$CONFIRM" | tr '[:upper:]' '[:lower:]') - -if [[ "$CONFIRM_LOWER" != "y" ]] && [[ "$CONFIRM_LOWER" != "yes" ]]; then - echo -e "${YELLOW}Cancelled.${NC}" - exit 0 -fi - -echo "" -echo -e "${BLUE}Starting batch rename...${NC}" -echo "" - -update_file() { - local file=$1 - local old=$2 - local new=$3 - - if [[ "$OSTYPE" == "darwin"* ]]; then - # macOS - sed -i '' "s|$old|$new|g" "$file" - else - # Linux - sed -i "s|$old|$new|g" "$file" - fi -} - -# 1. Update root Cargo.toml -echo -e "${GREEN}[OK]${NC} Updating Cargo.toml..." -update_file "Cargo.toml" "https://github.com/fast/template" "https://github.com/$GITHUB_USER/$PROJECT_NAME" -update_file "Cargo.toml" '"template"' "\"$PROJECT_NAME\"" - -# 2. Update template/Cargo.toml -echo -e "${GREEN}[OK]${NC} Updating template/Cargo.toml..." -update_file "template/Cargo.toml" 'name = "template"' "name = \"$PROJECT_NAME\"" - -# 3. Update README.md -echo -e "${GREEN}[OK]${NC} Updating README.md..." -update_file "README.md" "crates.io/crates/template" "crates.io/crates/$PROJECT_NAME" -update_file "README.md" "img.shields.io/crates/v/template.svg" "img.shields.io/crates/v/$PROJECT_NAME.svg" -update_file "README.md" "img.shields.io/crates/l/template" "img.shields.io/crates/l/$PROJECT_NAME" -update_file "README.md" "github.com/fast/template" "github.com/$GITHUB_USER/$PROJECT_NAME" -update_file "README.md" "docs.rs/template" "docs.rs/$PROJECT_NAME" - -# 4. Update .github/semantic.yml -echo -e "${GREEN}[OK]${NC} Updating .github/semantic.yml..." -update_file ".github/semantic.yml" "github.com/fast/template" "github.com/$GITHUB_USER/$PROJECT_NAME" - -# 5. Rename template directory -echo -e "${GREEN}[OK]${NC} Renaming template/ directory to $PROJECT_NAME/..." -if [[ -d "template" ]]; then - mv template "$PROJECT_NAME" -fi - -# 6. Update Cargo.lock -echo -e "${GREEN}[OK]${NC} Updating Cargo.lock..." -update_file "Cargo.lock" 'name = "template"' "name = \"$PROJECT_NAME\"" - -echo "" -echo -e "${GREEN}========================================${NC}" -echo -e "${GREEN} SUCCESS: Renaming completed! ${NC}" -echo -e "${GREEN}========================================${NC}" -echo "" -echo -e "${BLUE}Next steps:${NC}" -echo "" -echo -e " 1. Review the changes:" -echo -e " ${YELLOW}git diff${NC}" -echo "" -echo -e " 2. Delete the rename scripts (no longer needed):" -echo -e " ${YELLOW}rm rename-project.sh rename-project.ps1${NC}" -echo "" -echo -e " 3. Update the project description in README.md" -echo "" -echo -e " 4. Commit your changes:" -echo -e " ${YELLOW}git add .${NC}" -echo -e " ${YELLOW}git commit -m \"chore: initialize project as $PROJECT_NAME\"${NC}" -echo "" -echo -e " 5. Push to GitHub:" -echo -e " ${YELLOW}git push${NC}" -echo "" -echo -e "${GREEN}Happy coding!${NC}" - diff --git a/xtask/src/main.rs b/xtask/src/main.rs index f65e769..68a1c8e 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -14,11 +14,23 @@ //! An xtask binary for managing workspace tasks. +use std::io::Write; +use std::io::stdin; +use std::io::stdout; +use std::path::Path; use std::process::Command as StdCommand; use clap::Parser; use clap::Subcommand; +mod colors { + pub const RED: &str = "\x1b[31m"; + pub const GREEN: &str = "\x1b[32m"; + pub const YELLOW: &str = "\x1b[33m"; + pub const BLUE: &str = "\x1b[34m"; + pub const RESET: &str = "\x1b[0m"; +} + #[derive(Parser)] struct Command { #[clap(subcommand)] @@ -29,6 +41,7 @@ impl Command { fn run(self) { match self.sub { SubCommand::Build(cmd) => cmd.run(), + SubCommand::Bootstrap(cmd) => cmd.run(), SubCommand::Lint(cmd) => cmd.run(), SubCommand::Test(cmd) => cmd.run(), } @@ -39,6 +52,8 @@ impl Command { enum SubCommand { #[clap(about = "Compile workspace packages.")] Build(CommandBuild), + #[clap(about = "Bootstrap a new project from this template.")] + Bootstrap(CommandBootstrap), #[clap(about = "Run format and clippy checks.")] Lint(CommandLint), #[clap(about = "Run unit tests.")] @@ -57,6 +72,21 @@ impl CommandBuild { } } +#[derive(Parser)] +struct CommandBootstrap { + #[arg(long, value_parser=parse_project_name, help = "Name of the new project (e.g., my-awesome-project).")] + project_name: Option, + + #[arg(long, value_parser=parse_github_account, help = "GitHub username or organization (e.g., rust-lang).")] + github_account: Option, +} + +impl CommandBootstrap { + fn run(self) { + bootstrap_project(self.project_name, self.github_account); + } +} + #[derive(Parser)] struct CommandTest { #[arg(long, help = "Run tests serially and do not capture output.")] @@ -198,7 +228,320 @@ fn make_taplo_cmd(fix: bool) -> StdCommand { cmd } +/// Validates a project name according to Cargo's naming conventions. +/// +/// Adapted from Cargo's [`restricted_names`] validation. +/// +/// [`restricted_names`]: https://github.com/rust-lang/cargo/blob/master/crates/cargo-util-schemas/src/restricted_names.rs +/// +/// See also: +fn parse_project_name(name: &str) -> Result { + let name = name.trim(); + + if name.is_empty() { + return Err("project name cannot be empty".into()); + } + + let mut chars = name.chars(); + if let Some(ch) = chars.next() { + if ch.is_ascii_digit() { + return Err(format!("the name cannot start with a digit: '{}'", ch)); + } + if !(ch.is_ascii_alphabetic() || ch == '_') { + return Err(format!( + "the first character must be a letter or `_`, found: '{}'", + ch + )); + } + } + + for ch in chars { + if !(ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') { + return Err(format!( + "invalid character '{}': only letters, numbers, `-`, or `_` are allowed", + ch + )); + } + } + + Ok(name.to_owned()) +} + +fn parse_github_account(account_name: &str) -> Result { + let account_name = account_name.trim(); + if account_name.is_empty() { + return Err("GitHub account name cannot be empty".into()); + } + Ok(account_name.to_owned()) +} + +fn check_project_root() -> Result<(), String> { + if !Path::new("Cargo.toml").exists() || !Path::new("xtask").is_dir() { + return Err("This command must be run from the project root directory".into()); + } + + if !Path::new("template").is_dir() { + return Err("The 'template' directory not found. \ + This project may have already been bootstrapped." + .into()); + } + Ok(()) +} + +fn prompt_input(prompt: &str) -> String { + print!("{}: ", prompt); + stdout().flush().unwrap(); + let mut input = String::new(); + stdin().read_line(&mut input).unwrap(); + input.trim().to_owned() +} + +fn get_valid_input(prompt: &str, validator: F) -> String +where + F: Fn(&str) -> Result, +{ + loop { + let input = prompt_input(prompt); + match validator(&input) { + Ok(value) => return value, + Err(e) => eprintln!("{}ERROR: {e}{}", colors::RED, colors::RESET), + } + } +} + +fn bootstrap_project(project_name: Option, github_account: Option) { + if let Err(e) = check_project_root() { + eprintln!("{}ERROR: {e}{}", colors::RED, colors::RESET); + return; + } + print_bootstrap_title(); + let Some((project_name, github_account)) = prepare_inputs(project_name, github_account) else { + return; + }; + if preview_and_confirm(&project_name, &github_account).is_none() { + return; + }; + execute_bootstrap(&project_name, &github_account); + print_bootstrap_complete(&project_name); +} + +fn prepare_inputs( + project_name: Option, + github_account: Option, +) -> Option<(String, String)> { + let project_name = project_name + .unwrap_or_else(|| get_valid_input("Enter the new project name", parse_project_name)); + let github_account = github_account + .unwrap_or_else(|| get_valid_input("Enter the GitHub username/org", parse_github_account)); + Some((project_name, github_account)) +} + +fn preview_and_confirm(project_name: &str, github_account: &str) -> Option<()> { + print_bootstrap_preview(project_name, github_account); + confirm() + .then(|| { + println!( + "\n{}Starting batch rename...{}\n", + colors::BLUE, + colors::RESET + ) + }) + .or_else(|| { + println!("{}Cancelled.{}", colors::YELLOW, colors::RESET); + None + }) +} + +fn execute_bootstrap(project_name: &str, github_account: &str) { + update_root_cargo_toml(project_name, github_account); + update_project_cargo_toml(project_name); + update_readme(project_name, github_account); + update_semantic_yml(project_name, github_account); + update_cargo_lock(project_name); + update_project_dir(project_name); +} + +fn print_bootstrap_preview(project_name: &str, github_account: &str) { + println!( + "\n\ +{blue}Preview:{reset} + Project name: {green}{project_name}{reset} + GitHub repo: {green}{github_account}/{project_name}{reset} + Crates.io URL: {green}https://crates.io/crates/{project_name}{reset} +", + blue = colors::BLUE, + green = colors::GREEN, + reset = colors::RESET, + project_name = project_name, + github_account = github_account, + ); +} + +fn confirm() -> bool { + print!("Continue? (y/N): "); + stdout().flush().unwrap(); + + let mut input = String::new(); + stdin().read_line(&mut input).unwrap(); + matches!(input.trim().to_lowercase().as_str(), "y" | "yes") +} + +fn replace_in_file(file: &std::path::Path, old: &str, new: &str) -> Result<(), String> { + let content = std::fs::read_to_string(file).map_err(|e| e.to_string())?; + + if !content.contains(old) { + return Ok(()); + } + let content = content.replace(old, new); + + std::fs::write(file, content).map_err(|e| e.to_string()) +} + +fn print_task(task: impl AsRef) { + print!("{:.<50}", task.as_ref()); +} + +fn print_update_result(result: Result<(), String>) { + match result { + Ok(_) => println!("{}[OK]{}", colors::GREEN, colors::RESET), + Err(e) => eprintln!("{}[ERROR] {}{}", colors::RED, e, colors::RESET), + } +} + +fn update_root_cargo_toml(project_name: &str, github_account: &str) { + let file = Path::new("Cargo.toml"); + print_task(format!("Updating {}...", file.display())); + let result = replace_in_file(file, "/fast", &format!("/{}", github_account)) + .and_then(|_| replace_in_file(file, "template", project_name)); + + print_update_result(result); +} + +fn update_project_cargo_toml(project_name: &str) { + let file = Path::new("template/Cargo.toml"); + print_task(format!("Updating {}...", file.display())); + let result = replace_in_file(file, "template", project_name); + print_update_result(result); +} + +fn update_readme(project_name: &str, github_account: &str) { + let file = Path::new("README.md"); + print_task(format!("Updating {}...", file.display())); + let result = replace_in_file(file, "/fast", &format!("/{}", github_account)) + .and_then(|_| replace_in_file(file, "/template", &format!("/{}", project_name))); + print_update_result(result); +} + +fn update_semantic_yml(project_name: &str, github_account: &str) { + let file = Path::new(".github/semantic.yml"); + print_task(format!("Updating {}...", file.display())); + let result = replace_in_file( + file, + "/fast/template", + &format!("/{}/{}", github_account, project_name), + ); + print_update_result(result); +} + +fn update_cargo_lock(project_name: &str) { + let file = Path::new("Cargo.lock"); + print_task(format!("Updating {}...", file.display())); + let result = replace_in_file(file, "template", project_name); + print_update_result(result); +} + +fn update_project_dir(project_name: &str) { + print_task(format!( + "Renaming \"template/\" directory to \"{}/\" ...", + project_name + )); + let result = + std::fs::rename(Path::new("template"), Path::new(project_name)).map_err(|e| e.to_string()); + print_update_result(result); +} + +fn print_bootstrap_title() { + println!( + "\n\ +{blue}========================================{reset} +{blue} Template Project Bootstrapper {reset} +{blue}========================================{reset} +", + blue = colors::BLUE, + reset = colors::RESET, + ); +} + +fn print_bootstrap_complete(project_name: &str) { + println!( + "\n\ +{green}========================================{reset} +{green} Bootstrap completed! {reset} +{green}========================================{reset} + +{blue}Next steps:{reset} + +1. Review the changes: + {yellow}git diff{reset} + +2. Update the project description in README.md + +3. Commit your changes: + {yellow}git add .{reset} + {yellow}git commit -m \"chore: initialize project as {project_name}\"{reset} + +4. Push to GitHub: + {yellow}git push{reset} + +{green}Happy coding!{reset} +", + green = colors::GREEN, + blue = colors::BLUE, + yellow = colors::YELLOW, + reset = colors::RESET, + project_name = project_name + ); +} + fn main() { let cmd = Command::parse(); cmd.run() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_project_name() { + // valid names + assert_eq!(parse_project_name("myproject"), Ok("myproject".into())); + assert_eq!(parse_project_name("my-project"), Ok("my-project".into())); + assert_eq!(parse_project_name("my_project"), Ok("my_project".into())); + assert_eq!(parse_project_name("project123"), Ok("project123".into())); + assert_eq!(parse_project_name("_private"), Ok("_private".into())); + assert_eq!(parse_project_name("MyProject"), Ok("MyProject".into())); + assert_eq!(parse_project_name(" myproject "), Ok("myproject".into())); + + // invalid names + assert!(parse_project_name("").is_err()); + assert!(parse_project_name(" ").is_err()); + assert!(parse_project_name("123project").is_err()); + assert!(parse_project_name("-project").is_err()); + assert!(parse_project_name("my@project").is_err()); + assert!(parse_project_name("my project").is_err()); + assert!(parse_project_name("my.project").is_err()); + } + + #[test] + fn test_parse_github_account() { + // valid accounts + assert_eq!(parse_github_account("myuser"), Ok("myuser".into())); + assert_eq!(parse_github_account("my-org"), Ok("my-org".into())); + assert_eq!(parse_github_account(" myuser "), Ok("myuser".into())); + + // invalid accounts + assert!(parse_github_account("").is_err()); + assert!(parse_github_account(" ").is_err()); + } +}