Skip to main content

kick_rs_cli/
new.rs

1//! `cargo kick new` — scaffold a new kick-rs project.
2//!
3//! Walks the embedded template manifest and writes each file under a
4//! freshly-created project directory, substituting `{{project_name}}`
5//! / `{{project_name_snake}}` into the contents.
6
7use crate::templates::{render, Vars, FILES};
8use std::fs;
9use std::io;
10use std::path::{Path, PathBuf};
11
12/// Decoded form of the `new` subcommand's arguments.
13pub struct NewArgs {
14    pub name: String,
15    pub path: Option<PathBuf>,
16    pub force: bool,
17}
18
19/// User-facing error from the `new` flow. Stringly-typed because the
20/// CLI shows them to a human, not a downstream caller.
21#[derive(Debug)]
22pub enum NewError {
23    InvalidName(String),
24    AlreadyExists(PathBuf),
25    Io { path: PathBuf, source: io::Error },
26}
27
28impl std::fmt::Display for NewError {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self {
31            Self::InvalidName(name) => write!(
32                f,
33                "`{name}` is not a valid project name. Use lowercase letters, digits, hyphens, and underscores only (and start with a letter)."
34            ),
35            Self::AlreadyExists(p) => write!(
36                f,
37                "destination `{}` already exists. Re-run with --force to use it anyway (existing files inside are NOT removed).",
38                p.display()
39            ),
40            Self::Io { path, source } => write!(f, "I/O error at `{}`: {source}", path.display()),
41        }
42    }
43}
44
45impl std::error::Error for NewError {}
46
47/// Validate the project name. Mirrors what cargo itself accepts for
48/// crate names: lowercase ASCII letters / digits / `-` / `_`, must
49/// start with a letter, no consecutive hyphens.
50pub fn validate_name(name: &str) -> Result<(), NewError> {
51    let bad = |reason: &str| -> NewError { NewError::InvalidName(format!("{name} ({reason})")) };
52
53    if name.is_empty() {
54        return Err(bad("empty"));
55    }
56    let mut chars = name.chars();
57    let first = chars.next().unwrap();
58    if !first.is_ascii_lowercase() {
59        return Err(bad("must start with a lowercase letter"));
60    }
61    for c in chars {
62        let ok = c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_';
63        if !ok {
64            return Err(bad("illegal character"));
65        }
66    }
67    Ok(())
68}
69
70/// Run the scaffold against the given args. Returns the path that was
71/// written so the caller can echo it back.
72pub fn run(args: &NewArgs) -> Result<PathBuf, NewError> {
73    validate_name(&args.name)?;
74
75    let dest = args
76        .path
77        .clone()
78        .unwrap_or_else(|| PathBuf::from(&args.name));
79
80    if dest.exists() && !args.force {
81        return Err(NewError::AlreadyExists(dest));
82    }
83    if !dest.exists() {
84        fs::create_dir_all(&dest).map_err(|e| NewError::Io {
85            path: dest.clone(),
86            source: e,
87        })?;
88    }
89
90    let vars = Vars {
91        project_name: &args.name,
92        project_name_snake: &args.name.replace('-', "_"),
93    };
94
95    for (rel, contents) in FILES {
96        write_one(&dest, rel, contents, &vars)?;
97    }
98
99    Ok(dest)
100}
101
102fn write_one(dest: &Path, rel: &str, template: &str, vars: &Vars<'_>) -> Result<(), NewError> {
103    let target = dest.join(rel);
104    if let Some(parent) = target.parent() {
105        fs::create_dir_all(parent).map_err(|e| NewError::Io {
106            path: parent.to_path_buf(),
107            source: e,
108        })?;
109    }
110    let rendered = render(template, vars);
111    fs::write(&target, rendered).map_err(|e| NewError::Io {
112        path: target.clone(),
113        source: e,
114    })
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn validate_name_accepts_typical_names() {
123        assert!(validate_name("my-app").is_ok());
124        assert!(validate_name("my_app").is_ok());
125        assert!(validate_name("api1").is_ok());
126        assert!(validate_name("a").is_ok());
127    }
128
129    #[test]
130    fn validate_name_rejects_bad_names() {
131        assert!(validate_name("").is_err());
132        assert!(validate_name("1leading-digit").is_err());
133        assert!(validate_name("UPPER").is_err());
134        assert!(validate_name("has space").is_err());
135        assert!(validate_name("has.dot").is_err());
136    }
137
138    #[test]
139    fn run_scaffolds_into_tempdir() {
140        let tmp = tempfile::tempdir().unwrap();
141        let target = tmp.path().join("my-app");
142        let args = NewArgs {
143            name: "my-app".into(),
144            path: Some(target.clone()),
145            force: false,
146        };
147        let written = run(&args).unwrap();
148        assert_eq!(written, target);
149
150        // A couple of representative files exist with substitution applied.
151        let cargo = std::fs::read_to_string(target.join("Cargo.toml")).unwrap();
152        assert!(cargo.contains(r#"name        = "my-app""#));
153        let envex = std::fs::read_to_string(target.join(".env.example")).unwrap();
154        assert!(
155            envex.contains("my_app=debug"),
156            "expected snake-cased target in .env.example, got: {envex}"
157        );
158        // Module tree got laid out.
159        assert!(target.join("src/modules/hello/handlers.rs").is_file());
160    }
161
162    #[test]
163    fn run_refuses_to_overwrite_existing_dir() {
164        let tmp = tempfile::tempdir().unwrap();
165        let target = tmp.path().join("existing");
166        std::fs::create_dir(&target).unwrap();
167
168        let args = NewArgs {
169            name: "existing".into(),
170            path: Some(target.clone()),
171            force: false,
172        };
173        let err = run(&args).unwrap_err();
174        assert!(matches!(err, NewError::AlreadyExists(_)), "got {err:?}");
175    }
176
177    #[test]
178    fn run_with_force_writes_into_existing_dir() {
179        let tmp = tempfile::tempdir().unwrap();
180        let target = tmp.path().join("existing");
181        std::fs::create_dir(&target).unwrap();
182        std::fs::write(target.join("untouched.txt"), "stay put").unwrap();
183
184        let args = NewArgs {
185            name: "existing".into(),
186            path: Some(target.clone()),
187            force: true,
188        };
189        run(&args).unwrap();
190
191        // Pre-existing file is preserved (we don't wipe), template
192        // files now exist alongside.
193        assert!(target.join("untouched.txt").is_file());
194        assert!(target.join("Cargo.toml").is_file());
195    }
196}