Skip to main content

origin_xtask/
scaffold.rs

1//! `cargo xtask new` — a new application from the template.
2//!
3//! What the template gives a product on day one is not a blank window: it is the
4//! architecture contract, logging, error handling, settings, secrets, storage, the
5//! security profile, CI, and a module that is already testable without a desktop
6//! session.
7
8use crate::migrations::CURRENT;
9use std::path::{Path, PathBuf};
10
11/// Files copied verbatim; everything else is treated as text and substituted.
12const BINARY_EXTENSIONS: &[&str] = &["png", "ico", "icns", "jpg", "jpeg", "webp"];
13
14#[derive(Debug)]
15pub struct Options {
16    /// Directory and crate name, e.g. `my-app`.
17    pub slug: String,
18    /// Human-readable product name.
19    pub name: String,
20    /// Reverse-DNS identifier.
21    pub id: String,
22    /// Where to create the project.
23    pub into: PathBuf,
24    /// Point the dependencies at this Origin checkout instead of at crates.io.
25    ///
26    /// Used by Origin's own CI to prove the template still builds against `main`, and
27    /// useful while developing the platform and a product side by side.
28    pub local: Option<PathBuf>,
29}
30
31pub fn run(root: &Path, options: &Options) -> Result<(), String> {
32    validate_options(options)?;
33
34    let template = root.join("templates").join("app");
35    if !template.is_dir() {
36        return Err(format!(
37            "no template at {} — scaffolding currently needs the Origin repository",
38            template.display()
39        ));
40    }
41
42    let target = options.into.join(&options.slug);
43    if target.exists() {
44        return Err(format!("{} already exists", target.display()));
45    }
46
47    let substitutions = substitutions(options);
48    copy_template(&template, &target, &substitutions)?;
49
50    if let Some(origin) = &options.local {
51        patch_to_local(&target, origin)?;
52    }
53
54    // Leave a project that already passes its own checks, rather than one whose first
55    // CI run is red.
56    crate::generate(&target)?;
57
58    println!("created {}", target.display());
59    println!("\nnext:");
60    println!("  cd {}", target.display());
61    println!("  pnpm install");
62    println!("  cargo tauri dev");
63    println!("\nReplace the placeholder icons in src-tauri/icons before shipping.");
64    Ok(())
65}
66
67fn validate_options(options: &Options) -> Result<(), String> {
68    let valid_slug = !options.slug.is_empty()
69        && !options.slug.starts_with('-')
70        && !options.slug.ends_with('-')
71        && !options.slug.contains("--")
72        && options
73            .slug
74            .bytes()
75            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-');
76    if !valid_slug {
77        return Err(format!(
78            "invalid slug `{}` — use lowercase ASCII letters, digits and single path-safe hyphens",
79            options.slug
80        ));
81    }
82
83    if options.name.trim().is_empty()
84        || options
85            .name
86            .chars()
87            .any(|character| character.is_control() || matches!(character, '"' | '\\'))
88    {
89        return Err(
90            "product name must be non-empty and contain no control characters, quotes or backslashes"
91                .to_owned(),
92        );
93    }
94
95    let labels: Vec<&str> = options.id.split('.').collect();
96    let valid_id = labels.len() >= 2
97        && labels.iter().all(|label| {
98            !label.is_empty()
99                && label
100                    .bytes()
101                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
102                && label
103                    .bytes()
104                    .next()
105                    .is_some_and(|byte| byte.is_ascii_alphanumeric())
106                && label
107                    .bytes()
108                    .next_back()
109                    .is_some_and(|byte| byte.is_ascii_alphanumeric())
110        });
111    if !valid_id {
112        return Err(format!(
113            "invalid product id `{}` — expected a reverse-DNS identifier",
114            options.id
115        ));
116    }
117
118    Ok(())
119}
120
121fn substitutions(options: &Options) -> Vec<(&'static str, String)> {
122    vec![
123        ("__PRODUCT_ID__", options.id.clone()),
124        ("__PRODUCT_NAME__", options.name.clone()),
125        ("__CRATE_NAME_SNAKE__", options.slug.replace('-', "_")),
126        ("__CRATE_NAME__", options.slug.clone()),
127        ("__PACKAGE_NAME__", options.slug.clone()),
128        ("__ORIGIN_VERSION__", CURRENT.to_string()),
129        (
130            "__ORIGIN_SEMVER__",
131            format!("{}.{}", CURRENT.major, CURRENT.minor),
132        ),
133        ("__ORIGIN_NPM__", format!("^{CURRENT}")),
134    ]
135}
136
137fn copy_template(
138    template: &Path,
139    target: &Path,
140    substitutions: &[(&str, String)],
141) -> Result<(), String> {
142    std::fs::create_dir_all(target)
143        .map_err(|error| format!("cannot create {}: {error}", target.display()))?;
144
145    let entries = std::fs::read_dir(template)
146        .map_err(|error| format!("cannot read {}: {error}", template.display()))?;
147
148    for entry in entries.filter_map(Result::ok) {
149        let source = entry.path();
150        let destination = target.join(entry.file_name());
151
152        if source.is_dir() {
153            copy_template(&source, &destination, substitutions)?;
154            continue;
155        }
156
157        let is_binary = source
158            .extension()
159            .and_then(|extension| extension.to_str())
160            .is_some_and(|extension| BINARY_EXTENSIONS.contains(&extension));
161
162        if is_binary {
163            std::fs::copy(&source, &destination)
164                .map_err(|error| format!("cannot copy {}: {error}", source.display()))?;
165            continue;
166        }
167
168        let contents = std::fs::read_to_string(&source)
169            .map_err(|error| format!("cannot read {}: {error}", source.display()))?;
170
171        // Longest placeholder first, so `__CRATE_NAME_SNAKE__` is not eaten by
172        // `__CRATE_NAME__`.
173        let mut rendered = contents;
174        for (placeholder, value) in substitutions {
175            rendered = rendered.replace(placeholder, value);
176        }
177
178        std::fs::write(&destination, rendered)
179            .map_err(|error| format!("cannot write {}: {error}", destination.display()))?;
180    }
181
182    Ok(())
183}
184
185/// Point the generated project at a local Origin checkout.
186///
187/// Rewrites the dependencies rather than adding `[patch.crates-io]`: patching only
188/// works only for crates that exist in the registry. Used by Origin's own CI to prove
189/// the template still builds against `main`.
190fn patch_to_local(target: &Path, origin: &Path) -> Result<(), String> {
191    let origin = origin
192        .canonicalize()
193        .map_err(|error| format!("cannot resolve {}: {error}", origin.display()))?;
194
195    rewrite_cargo_dependencies(target, &origin)?;
196    rewrite_npm_dependencies(target, &origin)
197}
198
199/// Where each Origin crate lives inside the repository.
200fn crate_location(name: &str) -> &'static str {
201    match name {
202        "origin-tauri" => "host",
203        "origin-http-reqwest"
204        | "origin-notifications-tauri"
205        | "origin-secrets-system"
206        | "origin-storage-sqlite"
207        | "origin-auth-loopback" => "adapters",
208        _ => "crates",
209    }
210}
211
212fn rewrite_cargo_dependencies(target: &Path, origin: &Path) -> Result<(), String> {
213    let manifest = target.join("Cargo.toml");
214    let contents = std::fs::read_to_string(&manifest)
215        .map_err(|error| format!("cannot read {}: {error}", manifest.display()))?;
216
217    let mut document: toml_edit::DocumentMut = contents
218        .parse()
219        .map_err(|error| format!("{} is not valid TOML: {error}", manifest.display()))?;
220
221    let Some(dependencies) = document
222        .get_mut("workspace")
223        .and_then(|workspace| workspace.get_mut("dependencies"))
224        .and_then(toml_edit::Item::as_table_mut)
225    else {
226        return Err("template Cargo.toml has no [workspace.dependencies]".to_owned());
227    };
228
229    let names: Vec<String> = dependencies
230        .iter()
231        .map(|(name, _)| name.to_owned())
232        .filter(|name| name.starts_with("origin-"))
233        .collect();
234
235    for name in names {
236        let path = origin.join(crate_location(&name)).join(&name);
237        let mut value = toml_edit::InlineTable::new();
238        value.insert("path", path.display().to_string().into());
239        dependencies[&name] = toml_edit::value(value);
240    }
241
242    std::fs::write(&manifest, document.to_string())
243        .map_err(|error| format!("cannot write {}: {error}", manifest.display()))
244}
245
246fn rewrite_npm_dependencies(target: &Path, origin: &Path) -> Result<(), String> {
247    let manifest = target.join("ui").join("package.json");
248    let contents = std::fs::read_to_string(&manifest)
249        .map_err(|error| format!("cannot read {}: {error}", manifest.display()))?;
250
251    let client_release = format!("\"@origin/client\": \"^{CURRENT}\"");
252    let ui_release = format!("\"@origin/ui\": \"^{CURRENT}\"");
253    let rewritten = contents
254        .replace(
255            &client_release,
256            &format!(
257                "\"@origin/client\": \"link:{}\"",
258                origin.join("frontend").join("client").display()
259            ),
260        )
261        .replace(
262            &ui_release,
263            &format!(
264                "\"@origin/ui\": \"link:{}\"",
265                origin.join("frontend").join("ui").display()
266            ),
267        );
268
269    std::fs::write(&manifest, rewritten)
270        .map_err(|error| format!("cannot write {}: {error}", manifest.display()))
271}