Skip to content

Commit 3880af6

Browse files
authored
feat(scaffold): initial Rust project structure (#29)
- Add Cargo.toml with dependencies (clap, serde, anyhow, thiserror, etc.) - Add release profile optimized for binary size - Add CLI argument parsing with clap derive - Add rnr.yaml config parsing with serde - Add task runner with support for: - Simple commands (shorthand syntax) - Full task definitions (cmd, dir, env, description) - Sequential steps - Nested task delegation - Parallel blocks (placeholder for async implementation) - Add init command (creates .rnr/bin, wrapper scripts, starter config) - Add upgrade command (placeholder for binary updates) - Add list command (shows available tasks with descriptions) - Add .gitignore for Rust artifacts
1 parent 37bb9fb commit 3880af6

10 files changed

Lines changed: 699 additions & 0 deletions

File tree

.gitignore

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Rust build artifacts
2+
/target/
3+
**/*.rs.bk
4+
Cargo.lock
5+
6+
# IDE
7+
.idea/
8+
.vscode/
9+
*.swp
10+
*.swo
11+
12+
# OS files
13+
.DS_Store
14+
Thumbs.db
15+
16+
# Test artifacts
17+
*.log

Cargo.toml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
[package]
2+
name = "rnr"
3+
version = "0.1.0"
4+
edition = "2021"
5+
description = "A cross-platform task runner with zero setup"
6+
license = "MIT"
7+
repository = "https://github.com/CodingWithCalvin/rnr.cli"
8+
keywords = ["task-runner", "build", "automation", "cli"]
9+
categories = ["command-line-utilities", "development-tools"]
10+
11+
[dependencies]
12+
# CLI argument parsing
13+
clap = { version = "4", features = ["derive"] }
14+
15+
# YAML parsing
16+
serde = { version = "1", features = ["derive"] }
17+
serde_yaml = "0.9"
18+
19+
# Error handling
20+
anyhow = "1"
21+
thiserror = "1"
22+
23+
# Cross-platform support
24+
dirs = "5"
25+
26+
# HTTP client for init/upgrade
27+
reqwest = { version = "0.12", features = ["blocking"], default-features = false, optional = true }
28+
29+
# Async runtime for parallel execution
30+
tokio = { version = "1", features = ["rt-multi-thread", "process", "sync"], optional = true }
31+
32+
[features]
33+
default = ["network", "parallel"]
34+
network = ["reqwest"]
35+
parallel = ["tokio"]
36+
37+
[profile.release]
38+
opt-level = "z" # Optimize for size
39+
lto = true # Link-time optimization
40+
codegen-units = 1 # Single codegen unit for better optimization
41+
panic = "abort" # Abort on panic (smaller binary)
42+
strip = true # Strip symbols

src/cli.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
use clap::{Parser, Subcommand};
2+
3+
/// A cross-platform task runner with zero setup
4+
#[derive(Parser, Debug)]
5+
#[command(name = "rnr")]
6+
#[command(author, version, about, long_about = None)]
7+
pub struct Cli {
8+
/// Task to run
9+
#[arg(value_name = "TASK")]
10+
pub task: Option<String>,
11+
12+
/// List all available tasks
13+
#[arg(short, long)]
14+
pub list: bool,
15+
16+
#[command(subcommand)]
17+
pub command: Option<Command>,
18+
}
19+
20+
#[derive(Subcommand, Debug)]
21+
pub enum Command {
22+
/// Initialize rnr in the current directory
23+
Init,
24+
25+
/// Upgrade rnr binaries to the latest version
26+
Upgrade,
27+
}

src/commands/init.rs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
use anyhow::{Context, Result};
2+
use std::fs;
3+
use std::path::Path;
4+
5+
use crate::config::CONFIG_FILE;
6+
7+
/// Directory for rnr binaries
8+
const RNR_DIR: &str = ".rnr";
9+
const BIN_DIR: &str = "bin";
10+
11+
/// Run the init command
12+
pub fn run() -> Result<()> {
13+
let current_dir = std::env::current_dir().context("Failed to get current directory")?;
14+
15+
// Check if already initialized
16+
let rnr_dir = current_dir.join(RNR_DIR);
17+
if rnr_dir.exists() {
18+
println!("rnr is already initialized in this directory");
19+
return Ok(());
20+
}
21+
22+
println!("Initializing rnr...");
23+
24+
// Create .rnr/bin directory
25+
let bin_dir = rnr_dir.join(BIN_DIR);
26+
fs::create_dir_all(&bin_dir).context("Failed to create .rnr/bin directory")?;
27+
println!(" Created {}/{}", RNR_DIR, BIN_DIR);
28+
29+
// Download binaries for all platforms
30+
#[cfg(feature = "network")]
31+
{
32+
download_binaries(&bin_dir)?;
33+
}
34+
35+
#[cfg(not(feature = "network"))]
36+
{
37+
println!(" Skipping binary download (network feature disabled)");
38+
println!(" Please manually copy binaries to {}/{}", RNR_DIR, BIN_DIR);
39+
}
40+
41+
// Create wrapper scripts
42+
create_wrapper_scripts(&current_dir)?;
43+
44+
// Create starter rnr.yaml if it doesn't exist
45+
let config_path = current_dir.join(CONFIG_FILE);
46+
if !config_path.exists() {
47+
create_starter_config(&config_path)?;
48+
} else {
49+
println!(" {} already exists, skipping", CONFIG_FILE);
50+
}
51+
52+
println!("\nrnr initialized successfully!");
53+
println!("\nNext steps:");
54+
println!(" 1. Edit {} to define your tasks", CONFIG_FILE);
55+
println!(" 2. Run ./rnr --list to see available tasks");
56+
println!(" 3. Run ./rnr <task> to execute a task");
57+
println!(" 4. Commit the .rnr directory and wrapper scripts to your repo");
58+
59+
Ok(())
60+
}
61+
62+
/// Download binaries for all platforms
63+
#[cfg(feature = "network")]
64+
fn download_binaries(bin_dir: &Path) -> Result<()> {
65+
// TODO: Implement actual binary downloads from rnr.dev
66+
// For now, just create placeholder files
67+
println!(" Downloading binaries...");
68+
println!(" TODO: Download from https://rnr.dev/bin/latest/");
69+
70+
// Placeholder - in real implementation, download from server
71+
let platforms = ["rnr-linux", "rnr-macos", "rnr.exe"];
72+
for platform in platforms {
73+
let path = bin_dir.join(platform);
74+
fs::write(&path, "# placeholder binary\n")
75+
.with_context(|| format!("Failed to create {}", path.display()))?;
76+
println!(" Created {}", platform);
77+
}
78+
79+
Ok(())
80+
}
81+
82+
/// Create the wrapper scripts at the project root
83+
fn create_wrapper_scripts(project_root: &Path) -> Result<()> {
84+
// Unix wrapper script
85+
let unix_script = r#"#!/bin/sh
86+
exec "$(dirname "$0")/.rnr/bin/rnr-$(uname -s | tr A-Z a-z)" "$@"
87+
"#;
88+
89+
let unix_path = project_root.join("rnr");
90+
fs::write(&unix_path, unix_script).context("Failed to create rnr wrapper script")?;
91+
92+
// Make executable on Unix
93+
#[cfg(unix)]
94+
{
95+
use std::os::unix::fs::PermissionsExt;
96+
let mut perms = fs::metadata(&unix_path)?.permissions();
97+
perms.set_mode(0o755);
98+
fs::set_permissions(&unix_path, perms)?;
99+
}
100+
101+
println!(" Created rnr (Unix wrapper)");
102+
103+
// Windows wrapper script
104+
let windows_script = r#"@echo off
105+
"%~dp0.rnr\bin\rnr.exe" %*
106+
"#;
107+
108+
let windows_path = project_root.join("rnr.cmd");
109+
fs::write(&windows_path, windows_script).context("Failed to create rnr.cmd wrapper script")?;
110+
println!(" Created rnr.cmd (Windows wrapper)");
111+
112+
Ok(())
113+
}
114+
115+
/// Create a starter rnr.yaml configuration
116+
fn create_starter_config(path: &Path) -> Result<()> {
117+
let starter = r#"# rnr task definitions
118+
# See https://github.com/CodingWithCalvin/rnr.cli for documentation
119+
120+
# Simple command (shorthand)
121+
hello: echo "Hello from rnr!"
122+
123+
# Full task definition
124+
build:
125+
description: Build the project
126+
cmd: echo "Add your build command here"
127+
128+
# Task with steps
129+
ci:
130+
description: Run CI pipeline
131+
steps:
132+
- cmd: echo "Step 1: Lint"
133+
- cmd: echo "Step 2: Test"
134+
- cmd: echo "Step 3: Build"
135+
"#;
136+
137+
fs::write(path, starter).context("Failed to create rnr.yaml")?;
138+
println!(" Created {}", CONFIG_FILE);
139+
140+
Ok(())
141+
}

src/commands/list.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
use anyhow::Result;
2+
3+
use crate::config::{Config, TaskDef};
4+
5+
/// Run the list command
6+
pub fn run() -> Result<()> {
7+
let config = Config::load()?;
8+
9+
println!("\nAvailable tasks:\n");
10+
11+
let task_names = config.task_names();
12+
13+
if task_names.is_empty() {
14+
println!(" No tasks defined in rnr.yaml");
15+
return Ok(());
16+
}
17+
18+
// Find the longest task name for alignment
19+
let max_len = task_names.iter().map(|n| n.len()).max().unwrap_or(0);
20+
21+
for name in task_names {
22+
let description = get_task_description(&config, name);
23+
match description {
24+
Some(desc) => println!(" {:<width$} {}", name, desc, width = max_len),
25+
None => println!(" {}", name),
26+
}
27+
}
28+
29+
println!();
30+
Ok(())
31+
}
32+
33+
/// Get the description for a task, if any
34+
fn get_task_description(config: &Config, name: &str) -> Option<String> {
35+
match config.get_task(name)? {
36+
TaskDef::Shorthand(_) => None,
37+
TaskDef::Full(task) => task.description.clone(),
38+
}
39+
}

src/commands/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
pub mod init;
2+
pub mod list;
3+
pub mod upgrade;

src/commands/upgrade.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
use anyhow::{Context, Result};
2+
use std::path::PathBuf;
3+
4+
/// Run the upgrade command
5+
pub fn run() -> Result<()> {
6+
let rnr_dir = find_rnr_dir()?;
7+
let bin_dir = rnr_dir.join("bin");
8+
9+
if !bin_dir.exists() {
10+
anyhow::bail!("rnr is not initialized. Run 'rnr init' first.");
11+
}
12+
13+
println!("Upgrading rnr binaries...");
14+
15+
#[cfg(feature = "network")]
16+
{
17+
upgrade_binaries(&bin_dir)?;
18+
}
19+
20+
#[cfg(not(feature = "network"))]
21+
{
22+
println!("Network feature is disabled. Cannot download updates.");
23+
println!("Please manually update binaries in .rnr/bin/");
24+
}
25+
26+
Ok(())
27+
}
28+
29+
/// Find the .rnr directory by walking up from current directory
30+
fn find_rnr_dir() -> Result<PathBuf> {
31+
let current_dir = std::env::current_dir().context("Failed to get current directory")?;
32+
33+
let mut dir = current_dir.as_path();
34+
loop {
35+
let rnr_path = dir.join(".rnr");
36+
if rnr_path.exists() && rnr_path.is_dir() {
37+
return Ok(rnr_path);
38+
}
39+
40+
match dir.parent() {
41+
Some(parent) => dir = parent,
42+
None => break,
43+
}
44+
}
45+
46+
anyhow::bail!("No .rnr directory found. Run 'rnr init' first.")
47+
}
48+
49+
/// Download and replace binaries
50+
#[cfg(feature = "network")]
51+
fn upgrade_binaries(bin_dir: &std::path::Path) -> Result<()> {
52+
// TODO: Implement actual binary downloads
53+
// 1. Check current version
54+
// 2. Check latest version from server
55+
// 3. Download if newer version available
56+
// 4. Replace binaries
57+
58+
println!(" Checking for updates...");
59+
println!(" TODO: Check https://rnr.dev/bin/latest/");
60+
println!(" TODO: Download updated binaries");
61+
println!("\nUpgrade complete!");
62+
63+
// Placeholder for actual implementation
64+
let _ = bin_dir;
65+
66+
Ok(())
67+
}

0 commit comments

Comments
 (0)