1use std::env;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::time::Duration;
5
6use anyhow::{Context, Result, anyhow, bail};
7use cargo_generate::{GenerateArgs, TemplatePath, Vcs, generate};
8use clap::{Parser, ValueEnum};
9use include_dir::{Dir, DirEntry, include_dir};
10use pathdiff::diff_paths;
11use serde::Deserialize;
12use tempfile::TempDir;
13
14static BUNDLED_TEMPLATES: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/templates");
15
16const DEFAULT_GIT_URL: &str = "https://github.com/copper-project/copper-rs.git";
17const DEFAULT_COPPER_VERSION: &str = env!("CARGO_PKG_VERSION");
18const CRATES_IO_API: &str = "https://crates.io/api/v1/crates";
19const NO_TEMPLATE_GIT_REF: &str = "__none__";
20
21#[derive(Debug, Clone, Parser)]
22#[command(
23 about = "Bootstrap a new Copper project",
24 version,
25 after_help = "Examples:\n cargo cunew my_robot\n cargo cunew --template workspace my_workspace\n cargo cunew --source local --copper-root /path/to/copper-rs my_robot"
26)]
27pub struct Cli {
28 #[arg(value_name = "PATH")]
30 pub path: PathBuf,
31
32 #[arg(long, value_enum, default_value_t = TemplateKind::Project)]
34 pub template: TemplateKind,
35
36 #[arg(long, value_enum, default_value_t = SourceKind::CratesIo)]
38 pub source: SourceKind,
39
40 #[arg(long)]
42 pub name: Option<String>,
43
44 #[arg(long)]
46 pub copper_root: Option<PathBuf>,
47
48 #[arg(long, default_value = DEFAULT_GIT_URL)]
50 pub git_url: String,
51
52 #[arg(long)]
54 pub git_branch: Option<String>,
55
56 #[arg(long)]
58 pub git_tag: Option<String>,
59
60 #[arg(long)]
62 pub git_rev: Option<String>,
63
64 #[arg(long)]
66 pub no_vcs: bool,
67
68 #[arg(long)]
70 pub verbose: bool,
71}
72
73#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
74pub enum TemplateKind {
75 Project,
76 #[value(alias = "full")]
77 Workspace,
78}
79
80impl TemplateKind {
81 fn subfolder(self) -> &'static str {
82 match self {
83 Self::Project => "cu_project",
84 Self::Workspace => "cu_full",
85 }
86 }
87}
88
89#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
90pub enum SourceKind {
91 #[value(name = "crates.io", alias = "crates-io")]
92 CratesIo,
93 Git,
94 Local,
95}
96
97impl SourceKind {
98 fn as_template_value(self) -> &'static str {
99 match self {
100 Self::CratesIo => "crates.io",
101 Self::Git => "git",
102 Self::Local => "local",
103 }
104 }
105}
106
107#[derive(Debug, Clone)]
108struct CopperVersions {
109 cu29: String,
110 cu29_export: String,
111}
112
113#[derive(Debug, Clone)]
114struct ResolvedOptions {
115 project_name: String,
116 destination_dir: PathBuf,
117 vcs: Vcs,
118 defines: Vec<String>,
119}
120
121pub fn run(cli: Cli) -> Result<PathBuf> {
122 run_with_versions(cli, None)
123}
124
125fn run_with_versions(cli: Cli, versions_override: Option<CopperVersions>) -> Result<PathBuf> {
126 validate_git_options(&cli)?;
127 ensure_generator_identity_env()?;
128
129 let resolved = resolve_options(&cli, versions_override)?;
130 let bundled = materialize_bundled_templates()?;
131
132 let args = GenerateArgs {
133 name: Some(resolved.project_name),
134 destination: Some(resolved.destination_dir),
135 allow_commands: true,
136 define: resolved.defines,
137 no_workspace: true,
138 silent: true,
139 verbose: cli.verbose,
140 vcs: Some(resolved.vcs),
141 template_path: TemplatePath {
142 path: Some(
143 bundled
144 .path()
145 .join(cli.template.subfolder())
146 .to_string_lossy()
147 .into_owned(),
148 ),
149 ..TemplatePath::default()
150 },
151 ..GenerateArgs::default()
152 };
153
154 generate(args).context("failed to generate Copper project")
155}
156
157fn ensure_generator_identity_env() -> Result<()> {
158 let user = env::var("USER")
159 .ok()
160 .filter(|value| !value.trim().is_empty());
161 let username = env::var("USERNAME")
162 .ok()
163 .filter(|value| !value.trim().is_empty());
164
165 let identity = user
166 .clone()
167 .or(username.clone())
168 .or(detect_current_username()?);
169
170 let Some(identity) = identity else {
171 return Ok(());
172 };
173
174 unsafe {
177 if user.is_none() {
178 env::set_var("USER", &identity);
179 }
180 if username.is_none() {
181 env::set_var("USERNAME", &identity);
182 }
183 }
184
185 Ok(())
186}
187
188#[cfg(unix)]
189fn detect_current_username() -> Result<Option<String>> {
190 use std::ffi::CStr;
191
192 unsafe {
195 let passwd = libc::getpwuid(libc::geteuid());
196 if passwd.is_null() || (*passwd).pw_name.is_null() {
197 return Ok(None);
198 }
199
200 let username = CStr::from_ptr((*passwd).pw_name)
201 .to_str()
202 .context("current username is not valid UTF-8")?
203 .trim()
204 .to_owned();
205
206 if username.is_empty() {
207 return Ok(None);
208 }
209
210 Ok(Some(username))
211 }
212}
213
214#[cfg(not(unix))]
215fn detect_current_username() -> Result<Option<String>> {
216 Ok(None)
217}
218
219fn resolve_options(
220 cli: &Cli,
221 versions_override: Option<CopperVersions>,
222) -> Result<ResolvedOptions> {
223 let project_path = cli.path.clone();
224 let project_name = cli
225 .name
226 .clone()
227 .or_else(|| {
228 project_path
229 .file_name()
230 .map(|value| value.to_string_lossy().into_owned())
231 })
232 .filter(|value| !value.is_empty() && value != ".")
233 .ok_or_else(|| {
234 anyhow!(
235 "could not derive a project name from {}",
236 project_path.display()
237 )
238 })?;
239
240 let destination_dir = project_path
241 .parent()
242 .filter(|parent| !parent.as_os_str().is_empty())
243 .map(Path::to_path_buf)
244 .unwrap_or_else(|| PathBuf::from("."));
245
246 let destination_root = absolutize(&destination_dir)?;
247 let generated_root = destination_root.join(&project_name);
248
249 let versions = versions_override.unwrap_or_else(|| match cli.source {
250 SourceKind::CratesIo => resolve_crates_io_versions(),
251 _ => CopperVersions {
252 cu29: DEFAULT_COPPER_VERSION.to_owned(),
253 cu29_export: DEFAULT_COPPER_VERSION.to_owned(),
254 },
255 });
256
257 let copper_root = match cli.source {
258 SourceKind::Local => Some(resolve_copper_root(cli, &destination_root)?),
259 _ => None,
260 };
261
262 let defines = build_defines(cli, &versions, copper_root.as_deref(), &generated_root)?;
263
264 Ok(ResolvedOptions {
265 project_name,
266 destination_dir,
267 vcs: if cli.no_vcs { Vcs::None } else { Vcs::Git },
268 defines,
269 })
270}
271
272fn validate_git_options(cli: &Cli) -> Result<()> {
273 let git_ref_count = [&cli.git_branch, &cli.git_tag, &cli.git_rev]
274 .into_iter()
275 .filter(|value| value.is_some())
276 .count();
277
278 if git_ref_count > 1 {
279 bail!("use at most one of --git-branch, --git-tag, or --git-rev");
280 }
281
282 if cli.source != SourceKind::Git && git_ref_count > 0 {
283 bail!("git ref options require --source git");
284 }
285
286 if cli.source != SourceKind::Local && cli.copper_root.is_some() {
287 bail!("--copper-root requires --source local");
288 }
289
290 Ok(())
291}
292
293fn build_defines(
294 cli: &Cli,
295 versions: &CopperVersions,
296 copper_root: Option<&Path>,
297 generated_root: &Path,
298) -> Result<Vec<String>> {
299 let mut defines = vec![
300 format!("copper_source={}", cli.source.as_template_value()),
301 format!("copper_version={}", versions.cu29),
302 format!("copper_export_version={}", versions.cu29_export),
303 format!("copper_git_url={}", cli.git_url),
304 format!(
305 "copper_git_branch={}",
306 cli.git_branch.as_deref().unwrap_or(NO_TEMPLATE_GIT_REF)
307 ),
308 format!(
309 "copper_git_tag={}",
310 cli.git_tag.as_deref().unwrap_or(NO_TEMPLATE_GIT_REF)
311 ),
312 format!(
313 "copper_git_rev={}",
314 cli.git_rev.as_deref().unwrap_or(NO_TEMPLATE_GIT_REF)
315 ),
316 format!(
317 "copper_git_ref_snippet={}",
318 format_git_ref_snippet(
319 cli.git_branch.as_deref(),
320 cli.git_tag.as_deref(),
321 cli.git_rev.as_deref()
322 )
323 ),
324 ];
325
326 let copper_root_path = match copper_root {
327 Some(root) => relative_or_absolute_toml_path(root, generated_root)?,
328 None => "../..".to_owned(),
329 };
330 defines.push(format!("copper_root_path={copper_root_path}"));
331
332 Ok(defines)
333}
334
335fn resolve_copper_root(cli: &Cli, destination_root: &Path) -> Result<PathBuf> {
336 if let Some(root) = &cli.copper_root {
337 return validate_copper_root(root);
338 }
339
340 if let Some(root) =
341 detect_copper_root(&env::current_dir().context("failed to read current directory")?)
342 {
343 return Ok(root);
344 }
345
346 if let Some(root) = detect_copper_root(destination_root) {
347 return Ok(root);
348 }
349
350 bail!("could not detect a Copper checkout; pass --copper-root /path/to/copper-rs")
351}
352
353fn detect_copper_root(start: &Path) -> Option<PathBuf> {
354 start
355 .ancestors()
356 .find(|dir| is_copper_root(dir))
357 .map(Path::to_path_buf)
358}
359
360fn validate_copper_root(path: &Path) -> Result<PathBuf> {
361 let absolute = absolutize(path)?;
362 if is_copper_root(&absolute) {
363 absolute
364 .canonicalize()
365 .with_context(|| format!("failed to canonicalize {}", absolute.display()))
366 } else {
367 bail!(
368 "{} does not look like a Copper checkout (expected core/cu29/Cargo.toml and support/cargo_cunew/templates)",
369 absolute.display()
370 )
371 }
372}
373
374fn is_copper_root(path: &Path) -> bool {
375 path.join("core/cu29/Cargo.toml").is_file()
376 && path.join("support/cargo_cunew/templates").is_dir()
377}
378
379fn resolve_crates_io_versions() -> CopperVersions {
380 fetch_latest_stable_versions().unwrap_or_else(|error| {
381 eprintln!(
382 "warning: failed to query crates.io for Copper versions ({error:#}); falling back to {DEFAULT_COPPER_VERSION}"
383 );
384 CopperVersions {
385 cu29: DEFAULT_COPPER_VERSION.to_owned(),
386 cu29_export: DEFAULT_COPPER_VERSION.to_owned(),
387 }
388 })
389}
390
391fn fetch_latest_stable_versions() -> Result<CopperVersions> {
392 let client = reqwest::blocking::Client::builder()
393 .timeout(Duration::from_secs(5))
394 .user_agent(format!("cargo-cunew/{}", env!("CARGO_PKG_VERSION")))
395 .build()
396 .context("failed to build crates.io HTTP client")?;
397
398 Ok(CopperVersions {
399 cu29: fetch_crate_version(&client, "cu29")?,
400 cu29_export: fetch_crate_version(&client, "cu29-export")?,
401 })
402}
403
404fn fetch_crate_version(client: &reqwest::blocking::Client, crate_name: &str) -> Result<String> {
405 let response = client
406 .get(format!("{CRATES_IO_API}/{crate_name}"))
407 .send()
408 .with_context(|| format!("failed to query crates.io for {crate_name}"))?
409 .error_for_status()
410 .with_context(|| format!("crates.io returned an error for {crate_name}"))?;
411
412 let payload: CratesIoResponse = response
413 .json()
414 .with_context(|| format!("failed to decode crates.io response for {crate_name}"))?;
415
416 if payload.krate.max_stable_version.is_empty() {
417 bail!("crates.io did not return a stable version for {crate_name}");
418 }
419
420 Ok(payload.krate.max_stable_version)
421}
422
423#[derive(Debug, Deserialize)]
424struct CratesIoResponse {
425 #[serde(rename = "crate")]
426 krate: CratesIoCrate,
427}
428
429#[derive(Debug, Deserialize)]
430struct CratesIoCrate {
431 max_stable_version: String,
432}
433
434fn format_git_ref_snippet(branch: Option<&str>, tag: Option<&str>, rev: Option<&str>) -> String {
435 if let Some(branch) = branch {
436 return format!(", branch = \"{branch}\"");
437 }
438 if let Some(tag) = tag {
439 return format!(", tag = \"{tag}\"");
440 }
441 if let Some(rev) = rev {
442 return format!(", rev = \"{rev}\"");
443 }
444 String::new()
445}
446
447fn materialize_bundled_templates() -> Result<TempDir> {
448 let tempdir =
449 tempfile::tempdir().context("failed to create a temporary directory for templates")?;
450 write_dir(&BUNDLED_TEMPLATES, tempdir.path())?;
451 Ok(tempdir)
452}
453
454fn write_dir(dir: &Dir<'_>, destination: &Path) -> Result<()> {
455 fs::create_dir_all(destination)
456 .with_context(|| format!("failed to create {}", destination.display()))?;
457
458 for entry in dir.entries() {
459 match entry {
460 DirEntry::Dir(child) => {
461 let name = child
462 .path()
463 .file_name()
464 .ok_or_else(|| anyhow!("invalid embedded directory path"))?;
465 write_dir(child, &destination.join(name))?;
466 }
467 DirEntry::File(file) => {
468 let name = file
469 .path()
470 .file_name()
471 .ok_or_else(|| anyhow!("invalid embedded file path"))?;
472 let target = destination.join(name);
473 fs::write(&target, file.contents())
474 .with_context(|| format!("failed to write {}", target.display()))?;
475 }
476 }
477 }
478
479 Ok(())
480}
481
482fn relative_or_absolute_toml_path(target: &Path, from: &Path) -> Result<String> {
483 let target = absolutize(target)?;
484 let from = absolutize(from)?;
485 let from_parent = from.parent().unwrap_or(&from);
486 let target_parent = target.parent().unwrap_or(&target);
487
488 let prefer_relative = from.starts_with(target_parent) || target.starts_with(from_parent);
489 let path = if prefer_relative {
490 diff_paths(&target, &from).unwrap_or(target)
491 } else {
492 target
493 };
494 normalize_toml_path(&path)
495}
496
497fn normalize_toml_path(path: &Path) -> Result<String> {
498 let display = path
499 .to_str()
500 .ok_or_else(|| anyhow!("{} is not valid UTF-8", path.display()))?;
501 Ok(display.replace('\\', "/"))
502}
503
504fn absolutize(path: &Path) -> Result<PathBuf> {
505 if path.is_absolute() {
506 Ok(path.to_path_buf())
507 } else {
508 Ok(env::current_dir()
509 .context("failed to read current directory")?
510 .join(path))
511 }
512}
513
514pub fn normalize_cargo_subcommand_args<I>(args: I) -> Vec<String>
515where
516 I: IntoIterator,
517 I::Item: Into<String>,
518{
519 let mut normalized: Vec<String> = args.into_iter().map(Into::into).collect();
520 if normalized.get(1).is_some_and(|arg| arg == "cunew") {
521 normalized.remove(1);
522 }
523 normalized
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529
530 #[test]
531 fn strips_cargo_subcommand_marker() {
532 let args = normalize_cargo_subcommand_args(["cargo-cunew", "cunew", "robot"]);
533 assert_eq!(args, vec!["cargo-cunew", "robot"]);
534 }
535
536 #[test]
537 fn keeps_direct_binary_args() {
538 let args = normalize_cargo_subcommand_args(["cargo-cunew", "robot"]);
539 assert_eq!(args, vec!["cargo-cunew", "robot"]);
540 }
541
542 #[test]
543 fn formats_git_ref_snippets() {
544 assert_eq!(
545 format_git_ref_snippet(Some("main"), None, None),
546 ", branch = \"main\""
547 );
548 assert_eq!(
549 format_git_ref_snippet(None, Some("v1.0.0"), None),
550 ", tag = \"v1.0.0\""
551 );
552 assert_eq!(
553 format_git_ref_snippet(None, None, Some("abc123")),
554 ", rev = \"abc123\""
555 );
556 assert!(format_git_ref_snippet(None, None, None).is_empty());
557 }
558
559 #[test]
560 fn materializes_bundled_templates_with_hidden_files() {
561 let templates = materialize_bundled_templates().expect("templates should materialize");
562 assert!(templates.path().join(".cargo/config.toml").is_file());
563 assert!(templates.path().join("cu_project/.gitignore").is_file());
564 assert!(
565 templates
566 .path()
567 .join("cu_full/apps/cu_example_app/logs/.keep")
568 .is_file()
569 );
570 }
571
572 #[test]
573 fn generates_project_template_for_crates_io() {
574 let tempdir = tempfile::tempdir().expect("tempdir");
575 let project = tempdir.path().join("hello-copper");
576 let cli = Cli {
577 path: project.clone(),
578 template: TemplateKind::Project,
579 source: SourceKind::CratesIo,
580 name: None,
581 copper_root: None,
582 git_url: DEFAULT_GIT_URL.to_owned(),
583 git_branch: None,
584 git_tag: None,
585 git_rev: None,
586 no_vcs: true,
587 verbose: false,
588 };
589
590 run_with_versions(
591 cli,
592 Some(CopperVersions {
593 cu29: "9.9.9".to_owned(),
594 cu29_export: "9.9.8".to_owned(),
595 }),
596 )
597 .expect("generation should succeed");
598
599 let manifest = fs::read_to_string(project.join("Cargo.toml")).expect("manifest");
600 let justfile = fs::read_to_string(project.join("justfile")).expect("justfile");
601
602 assert!(manifest.contains("edition = \"2024\""));
603 assert!(manifest.contains("version = \"9.9.9\""));
604 assert!(manifest.contains("version = \"9.9.8\""));
605 assert!(manifest.contains("cu29-export"));
606 assert!(!manifest.contains("\n[workspace]\n"));
607 assert!(justfile.contains("cargo install --locked cu29-runtime --version \"9.9.9\""));
608 assert!(justfile.contains("dag:"));
609 }
610
611 #[test]
612 fn generates_workspace_template_for_local_checkout() {
613 let tempdir = tempfile::tempdir().expect("tempdir");
614 let project = tempdir.path().join("hello-workspace");
615 let cli = Cli {
616 path: project.clone(),
617 template: TemplateKind::Workspace,
618 source: SourceKind::Local,
619 name: None,
620 copper_root: Some(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")),
621 git_url: DEFAULT_GIT_URL.to_owned(),
622 git_branch: None,
623 git_tag: None,
624 git_rev: None,
625 no_vcs: true,
626 verbose: false,
627 };
628
629 run_with_versions(
630 cli,
631 Some(CopperVersions {
632 cu29: "0.0.0".to_owned(),
633 cu29_export: "0.0.0".to_owned(),
634 }),
635 )
636 .expect("generation should succeed");
637
638 let manifest = fs::read_to_string(project.join("Cargo.toml")).expect("manifest");
639 let app_manifest = fs::read_to_string(project.join("apps/cu_example_app/Cargo.toml"))
640 .expect("app manifest");
641 let justfile = fs::read_to_string(project.join("justfile")).expect("justfile");
642
643 assert!(manifest.contains("core/cu29"));
644 assert!(manifest.contains("core/cu29_export"));
645 assert!(app_manifest.contains("edition = \"2024\""));
646 assert!(justfile.contains("cu29-rendercfg"));
647 assert!(!project.join(".git").exists());
648 }
649
650 #[test]
651 fn generates_project_template_for_git_source() {
652 let tempdir = tempfile::tempdir().expect("tempdir");
653 let project = tempdir.path().join("hello-git");
654 let cli = Cli {
655 path: project.clone(),
656 template: TemplateKind::Project,
657 source: SourceKind::Git,
658 name: None,
659 copper_root: None,
660 git_url: DEFAULT_GIT_URL.to_owned(),
661 git_branch: Some("main".to_owned()),
662 git_tag: None,
663 git_rev: None,
664 no_vcs: true,
665 verbose: false,
666 };
667
668 run_with_versions(
669 cli,
670 Some(CopperVersions {
671 cu29: "1.2.3".to_owned(),
672 cu29_export: "1.2.4".to_owned(),
673 }),
674 )
675 .expect("generation should succeed");
676
677 let manifest = fs::read_to_string(project.join("Cargo.toml")).expect("manifest");
678 let justfile = fs::read_to_string(project.join("justfile")).expect("justfile");
679
680 assert!(manifest.contains("git = \"https://github.com/copper-project/copper-rs.git\""));
681 assert!(manifest.contains("branch = \"main\""));
682 assert!(justfile.contains("cargo install --locked --git"));
683 assert!(justfile.contains("--branch \"main\""));
684 }
685}