Skip to content

Commit 66dbe3d

Browse files
alanbldclaude
andcommitted
feat: Add utf8proj init command for new users
- Add `init` subcommand that creates a starter project file - Generated template includes: project declaration, sample resources, sample tasks with dependencies, and a milestone - Template has helpful comments explaining DSL syntax and next steps - Sanitizes project name for safe filenames (spaces → underscores) - Refuses to overwrite existing files - Add 5 integration tests for init command - Update README with proper installation instructions (binary first) Improves onboarding for users discovering utf8proj via YouTube/Reddit. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent dcfe704 commit 66dbe3d

File tree

3 files changed

+319
-2
lines changed

3 files changed

+319
-2
lines changed

README.md

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,52 @@
4848

4949
---
5050

51+
## Installation
52+
53+
### Download Binary (Recommended)
54+
55+
**Linux:**
56+
```bash
57+
curl -LO https://github.com/alanbld/utf8proj/releases/latest/download/utf8proj-v0.9.1-x86_64-unknown-linux-gnu.tar.gz
58+
tar xzf utf8proj-v0.9.1-x86_64-unknown-linux-gnu.tar.gz
59+
sudo mv utf8proj /usr/local/bin/
60+
```
61+
62+
**macOS (Apple Silicon):**
63+
```bash
64+
curl -LO https://github.com/alanbld/utf8proj/releases/latest/download/utf8proj-v0.9.1-aarch64-apple-darwin.tar.gz
65+
tar xzf utf8proj-v0.9.1-aarch64-apple-darwin.tar.gz
66+
sudo mv utf8proj /usr/local/bin/
67+
```
68+
69+
**macOS (Intel):**
70+
```bash
71+
curl -LO https://github.com/alanbld/utf8proj/releases/latest/download/utf8proj-v0.9.1-x86_64-apple-darwin.tar.gz
72+
tar xzf utf8proj-v0.9.1-x86_64-apple-darwin.tar.gz
73+
sudo mv utf8proj /usr/local/bin/
74+
```
75+
76+
**Windows (PowerShell):**
77+
```powershell
78+
Invoke-WebRequest -Uri "https://github.com/alanbld/utf8proj/releases/latest/download/utf8proj-v0.9.1-x86_64-pc-windows-msvc.zip" -OutFile utf8proj.zip
79+
Expand-Archive utf8proj.zip -DestinationPath .
80+
```
81+
82+
### Build from Source (Rust users)
83+
84+
```bash
85+
cargo install utf8proj-cli
86+
```
87+
88+
### Verify Installation
89+
90+
```bash
91+
utf8proj --version
92+
# utf8proj 0.9.1
93+
```
94+
95+
---
96+
5197
## Quick Start (60 seconds)
5298

5399
**1. Create `example.proj`:**
@@ -62,10 +108,9 @@ task build "Build" { effort: 5d, assign: dev, depends: design }
62108
milestone done "Done" { depends: build }
63109
```
64110

65-
**2. Install and run:**
111+
**2. Run:**
66112

67113
```bash
68-
cargo install --path crates/utf8proj-cli
69114
utf8proj schedule example.proj
70115
```
71116

crates/utf8proj-cli/src/main.rs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,17 @@ enum Commands {
232232
#[command(subcommand)]
233233
fix_command: FixCommands,
234234
},
235+
236+
/// Initialize a new project file with a working example
237+
Init {
238+
/// Project name (default: "my-project")
239+
#[arg(value_name = "NAME", default_value = "my-project")]
240+
name: String,
241+
242+
/// Output directory (default: current directory)
243+
#[arg(short, long)]
244+
output: Option<std::path::PathBuf>,
245+
},
235246
}
236247

237248
#[derive(Subcommand)]
@@ -350,12 +361,14 @@ fn main() -> Result<()> {
350361
in_place,
351362
} => cmd_fix_container_deps(&file, output.as_deref(), in_place),
352363
},
364+
Some(Commands::Init { name, output }) => cmd_init(&name, output.as_deref()),
353365
None => {
354366
println!("utf8proj - Project Scheduling Engine");
355367
println!();
356368
println!("Usage: utf8proj <COMMAND>");
357369
println!();
358370
println!("Commands:");
371+
println!(" init Initialize a new project file");
359372
println!(" check Parse and validate a project file");
360373
println!(" schedule Schedule a project and output results");
361374
println!(" gantt Generate a Gantt chart (SVG)");
@@ -2022,3 +2035,131 @@ fn cmd_classify(file: &std::path::Path, by: &str) -> Result<()> {
20222035

20232036
Ok(())
20242037
}
2038+
2039+
// ============================================================================
2040+
// Init Command
2041+
// ============================================================================
2042+
2043+
/// Initialize a new project file with a working example
2044+
fn cmd_init(name: &str, output_dir: Option<&std::path::Path>) -> Result<()> {
2045+
use chrono::Local;
2046+
2047+
// Determine output directory
2048+
let dir = output_dir
2049+
.map(std::path::PathBuf::from)
2050+
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| ".".into()));
2051+
2052+
// Sanitize project name for filename (replace spaces/special chars with underscores)
2053+
let filename: String = name
2054+
.chars()
2055+
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
2056+
.collect();
2057+
let filepath = dir.join(format!("{}.proj", filename));
2058+
2059+
// Check if file already exists
2060+
if filepath.exists() {
2061+
anyhow::bail!(
2062+
"File '{}' already exists. Use a different name or delete the existing file.",
2063+
filepath.display()
2064+
);
2065+
}
2066+
2067+
// Get today's date for the project start
2068+
let today = Local::now().format("%Y-%m-%d");
2069+
2070+
// Generate project template
2071+
let template = format!(
2072+
r#"# {name}
2073+
#
2074+
# This is a utf8proj project file. Edit this file to define your project schedule.
2075+
#
2076+
# Quick reference:
2077+
# - Tasks have duration (calendar time) or effort (work time)
2078+
# - Dependencies: FS (finish-to-start, default), SS, FF, SF
2079+
# - Lag: +2d (delay), -1d (lead)
2080+
# - Resources: assign tasks to named resources
2081+
#
2082+
# Commands:
2083+
# utf8proj schedule {filename}.proj # Show schedule
2084+
# utf8proj gantt {filename}.proj -o gantt.html -f html # Visual Gantt chart
2085+
# utf8proj check {filename}.proj # Validate only
2086+
#
2087+
# Docs: https://github.com/alanbld/utf8proj
2088+
2089+
project "{name}" {{
2090+
start: {today}
2091+
}}
2092+
2093+
# Define your resources
2094+
resource dev "Developer" {{
2095+
rate: 800/day
2096+
}}
2097+
2098+
resource design "Designer" {{
2099+
rate: 600/day
2100+
}}
2101+
2102+
# Define your tasks
2103+
# Tip: Tasks with 'depends:' create a schedule chain
2104+
2105+
task planning "Planning" {{
2106+
duration: 3d
2107+
assign: dev
2108+
}}
2109+
2110+
task design "Design Phase" {{
2111+
duration: 5d
2112+
depends: planning
2113+
assign: design
2114+
}}
2115+
2116+
task development "Development" {{
2117+
duration: 10d
2118+
depends: design
2119+
assign: dev
2120+
}}
2121+
2122+
task testing "Testing" {{
2123+
duration: 3d
2124+
depends: development
2125+
assign: dev
2126+
}}
2127+
2128+
milestone launch "Project Launch" {{
2129+
depends: testing
2130+
}}
2131+
2132+
# Next steps:
2133+
# 1. Edit the tasks above to match your project
2134+
# 2. Run: utf8proj schedule {filename}.proj
2135+
# 3. Generate Gantt: utf8proj gantt {filename}.proj -o gantt.html -f html
2136+
"#,
2137+
name = name,
2138+
filename = filename,
2139+
today = today
2140+
);
2141+
2142+
// Create parent directory if needed
2143+
if let Some(parent) = filepath.parent() {
2144+
if !parent.exists() {
2145+
fs::create_dir_all(parent)
2146+
.with_context(|| format!("Failed to create directory '{}'", parent.display()))?;
2147+
}
2148+
}
2149+
2150+
// Write the file
2151+
fs::write(&filepath, &template)
2152+
.with_context(|| format!("Failed to write '{}'", filepath.display()))?;
2153+
2154+
println!("Created: {}", filepath.display());
2155+
println!();
2156+
println!("Next steps:");
2157+
println!(" 1. Edit {} to define your project", filepath.display());
2158+
println!(" 2. Run: utf8proj schedule {}", filepath.display());
2159+
println!(
2160+
" 3. Generate Gantt: utf8proj gantt {} -o gantt.html -f html",
2161+
filepath.display()
2162+
);
2163+
2164+
Ok(())
2165+
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
//! Tests for the `utf8proj init` command
2+
3+
use std::fs;
4+
use std::path::PathBuf;
5+
use std::process::Command;
6+
use tempfile::tempdir;
7+
8+
fn utf8proj_binary() -> PathBuf {
9+
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
10+
.parent()
11+
.unwrap()
12+
.parent()
13+
.unwrap()
14+
.join("target/debug/utf8proj")
15+
}
16+
17+
#[test]
18+
fn init_creates_project_file() {
19+
let dir = tempdir().unwrap();
20+
let expected_file = dir.path().join("test-project.proj");
21+
22+
let output = Command::new(utf8proj_binary())
23+
.args(["init", "test-project", "-o"])
24+
.arg(dir.path())
25+
.output()
26+
.expect("Failed to execute command");
27+
28+
assert!(output.status.success(), "Command should succeed");
29+
let stdout = String::from_utf8_lossy(&output.stdout);
30+
assert!(stdout.contains("Created:"), "Should show 'Created:'");
31+
assert!(
32+
stdout.contains("test-project.proj"),
33+
"Should show filename"
34+
);
35+
assert!(expected_file.exists(), "File should be created");
36+
37+
// Verify content has expected structure
38+
let content = fs::read_to_string(&expected_file).unwrap();
39+
assert!(
40+
content.contains("project \"test-project\""),
41+
"Should have project declaration"
42+
);
43+
assert!(
44+
content.contains("task planning"),
45+
"Should have planning task"
46+
);
47+
assert!(content.contains("depends:"), "Should have dependencies");
48+
}
49+
50+
#[test]
51+
fn init_refuses_overwrite() {
52+
let dir = tempdir().unwrap();
53+
let existing_file = dir.path().join("existing.proj");
54+
55+
// Create existing file
56+
fs::write(&existing_file, "# existing").unwrap();
57+
58+
let output = Command::new(utf8proj_binary())
59+
.args(["init", "existing", "-o"])
60+
.arg(dir.path())
61+
.output()
62+
.expect("Failed to execute command");
63+
64+
assert!(!output.status.success(), "Command should fail");
65+
let stderr = String::from_utf8_lossy(&output.stderr);
66+
assert!(
67+
stderr.contains("already exists"),
68+
"Should say file already exists"
69+
);
70+
}
71+
72+
#[test]
73+
fn init_sanitizes_filename() {
74+
let dir = tempdir().unwrap();
75+
76+
let output = Command::new(utf8proj_binary())
77+
.args(["init", "My Cool Project!", "-o"])
78+
.arg(dir.path())
79+
.output()
80+
.expect("Failed to execute command");
81+
82+
assert!(output.status.success(), "Command should succeed");
83+
84+
// Special chars replaced with underscores
85+
assert!(
86+
dir.path().join("My_Cool_Project_.proj").exists(),
87+
"Filename should be sanitized"
88+
);
89+
}
90+
91+
#[test]
92+
fn init_generated_file_schedules() {
93+
let dir = tempdir().unwrap();
94+
let project_file = dir.path().join("demo.proj");
95+
96+
// Create project
97+
let output = Command::new(utf8proj_binary())
98+
.args(["init", "demo", "-o"])
99+
.arg(dir.path())
100+
.output()
101+
.expect("Failed to execute command");
102+
assert!(output.status.success(), "init should succeed");
103+
104+
// Schedule it
105+
let output = Command::new(utf8proj_binary())
106+
.args(["schedule"])
107+
.arg(&project_file)
108+
.output()
109+
.expect("Failed to execute schedule");
110+
111+
assert!(output.status.success(), "schedule should succeed");
112+
let stdout = String::from_utf8_lossy(&output.stdout);
113+
assert!(stdout.contains("Critical Path:"), "Should show critical path");
114+
}
115+
116+
#[test]
117+
fn init_default_name() {
118+
let dir = tempdir().unwrap();
119+
120+
let output = Command::new(utf8proj_binary())
121+
.args(["init", "-o"])
122+
.arg(dir.path())
123+
.output()
124+
.expect("Failed to execute command");
125+
126+
assert!(output.status.success(), "Command should succeed");
127+
assert!(
128+
dir.path().join("my-project.proj").exists(),
129+
"Default name should be my-project"
130+
);
131+
}

0 commit comments

Comments
 (0)