Skip to main content

cargo_cunew/
lib.rs

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    /// Destination directory for the generated project.
29    #[arg(value_name = "PATH")]
30    pub path: PathBuf,
31
32    /// Template to generate.
33    #[arg(long, value_enum, default_value_t = TemplateKind::Project)]
34    pub template: TemplateKind,
35
36    /// Copper dependency source.
37    #[arg(long, value_enum, default_value_t = SourceKind::CratesIo)]
38    pub source: SourceKind,
39
40    /// Override the generated Cargo package name.
41    #[arg(long)]
42    pub name: Option<String>,
43
44    /// Path to a local Copper checkout when using --source local.
45    #[arg(long)]
46    pub copper_root: Option<PathBuf>,
47
48    /// Git URL when using --source git.
49    #[arg(long, default_value = DEFAULT_GIT_URL)]
50    pub git_url: String,
51
52    /// Git branch when using --source git.
53    #[arg(long)]
54    pub git_branch: Option<String>,
55
56    /// Git tag when using --source git.
57    #[arg(long)]
58    pub git_tag: Option<String>,
59
60    /// Git revision when using --source git.
61    #[arg(long)]
62    pub git_rev: Option<String>,
63
64    /// Skip initializing a git repository in the generated project.
65    #[arg(long)]
66    pub no_vcs: bool,
67
68    /// Increase cargo-generate verbosity.
69    #[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    // cargo-generate reads process environment to synthesize its built-in
175    // `authors`/`username` variables. Set the missing variables once up front.
176    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    // This runs before cargo-generate starts and before we spawn worker
193    // threads, so using the libc account lookup here is acceptable.
194    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}