Skip to main content

origin_xtask/
generate.rs

1//! Files derived from `app.toml` (ADR-0021, ADR-0022).
2//!
3//! Generated files are Origin-owned: they are overwritten on every run and must not be
4//! edited. That is what makes an Origin upgrade a regeneration rather than a merge.
5
6use crate::contracts;
7use origin_manifest::{Capability, Manifest};
8use std::fmt::Write as _;
9use std::path::{Path, PathBuf};
10
11/// Marker written into every generated file, and the thing that tells the generator
12/// which files it is allowed to remove.
13const MARKER: &str = "Generated from app.toml";
14
15/// Write everything derived from the manifests found in the workspace.
16pub fn run(root: &Path) -> Result<(), String> {
17    // Contracts are generated only where `@origin/client` itself lives — that is the
18    // Origin repository. A derivative consumes the package and gets the bindings with
19    // it, rather than regenerating a copy that could differ.
20    if let Some(contracts) = contracts_target(root) {
21        write_if_changed(&contracts, &contracts::render()?)?;
22        println!("generated {}", relative(root, &contracts));
23    }
24
25    let manifests = find_manifests(root)?;
26    if manifests.is_empty() {
27        return Err(format!("no app.toml found under {}", root.display()));
28    }
29
30    for path in manifests {
31        let manifest = load(&path)?;
32        let directory = tauri_directory(&path);
33
34        for (file, contents) in outputs(&manifest, &directory)? {
35            write_if_changed(&file, &contents)?;
36        }
37
38        remove_stale_capabilities(&manifest, &directory)?;
39        println!("generated files for {}", relative(root, &path));
40    }
41
42    Ok(())
43}
44
45/// Fail if anything generated is missing, stale, or was edited by hand.
46///
47/// Run in CI: a hand-edited generated file survives review far too easily, and a stale
48/// one means the manifest and the build disagree.
49pub fn check(root: &Path) -> Result<(), String> {
50    let manifests = find_manifests(root)?;
51    if manifests.is_empty() {
52        return Err(format!("no app.toml found under {}", root.display()));
53    }
54
55    let mut problems = Vec::new();
56
57    check_contracts(root, &mut problems)?;
58
59    for path in manifests {
60        let manifest = load(&path)?;
61        let directory = tauri_directory(&path);
62
63        check_manifest_outputs(root, &manifest, &directory, &mut problems)?;
64        check_stale_capabilities(root, &manifest, &directory, &mut problems)?;
65    }
66
67    if problems.is_empty() {
68        println!("generated files: up to date");
69        return Ok(());
70    }
71
72    let mut report = String::from("generated files are out of date:\n");
73    for problem in &problems {
74        let _ = writeln!(report, "  - {problem}");
75    }
76    Err(report)
77}
78
79/// Compare the generated contracts bindings against what the Rust definitions would
80/// produce, appending a problem if they differ or are missing.
81fn check_contracts(root: &Path, problems: &mut Vec<String>) -> Result<(), String> {
82    let Some(contracts_path) = contracts_target(root) else {
83        return Ok(());
84    };
85
86    match std::fs::read_to_string(&contracts_path) {
87        Ok(actual) if actual == contracts::render()? => {}
88        Ok(_) => problems.push(format!(
89            "{} no longer matches the Rust contracts — run `cargo xtask generate`",
90            relative(root, &contracts_path)
91        )),
92        Err(_) => problems.push(format!(
93            "{} is missing — run `cargo xtask generate`",
94            relative(root, &contracts_path)
95        )),
96    }
97
98    Ok(())
99}
100
101/// Compare one manifest's generated outputs against what is on disk, appending a
102/// problem for each file that differs or is missing.
103fn check_manifest_outputs(
104    root: &Path,
105    manifest: &Manifest,
106    directory: &Path,
107    problems: &mut Vec<String>,
108) -> Result<(), String> {
109    for (file, expected) in outputs(manifest, directory)? {
110        match std::fs::read_to_string(&file) {
111            Ok(actual) if actual == expected => {}
112            Ok(_) => problems.push(format!(
113                "{} differs from what app.toml describes — run `cargo xtask generate`",
114                relative(root, &file)
115            )),
116            Err(_) => problems.push(format!(
117                "{} is missing — run `cargo xtask generate`",
118                relative(root, &file)
119            )),
120        }
121    }
122
123    Ok(())
124}
125
126/// Append a problem for each capability file this generator produced earlier but no
127/// longer would.
128fn check_stale_capabilities(
129    root: &Path,
130    manifest: &Manifest,
131    directory: &Path,
132    problems: &mut Vec<String>,
133) -> Result<(), String> {
134    for stale in stale_capabilities(manifest, directory)? {
135        problems.push(format!(
136            "{} is generated but no window uses its profile — run `cargo xtask generate`",
137            relative(root, &stale)
138        ));
139    }
140
141    Ok(())
142}
143
144/// Where generated bindings belong, if this workspace holds `@origin/client`.
145fn contracts_target(root: &Path) -> Option<PathBuf> {
146    let path = contracts::output_path(root);
147    path.parent().is_some_and(Path::is_dir).then_some(path)
148}
149
150/// Everything one manifest produces, as `(path, contents)`.
151fn outputs(manifest: &Manifest, tauri_directory: &Path) -> Result<Vec<(PathBuf, String)>, String> {
152    let mut outputs = Vec::new();
153    let capabilities = tauri_directory.join("capabilities");
154
155    for capability in Capability::from_manifest(manifest) {
156        let json = serde_json::to_string_pretty(&capability)
157            .map_err(|error| format!("cannot encode capability: {error}"))?;
158
159        outputs.push((
160            capabilities.join(capability.file_name()),
161            format!("{json}\n"),
162        ));
163    }
164
165    Ok(outputs)
166}
167
168/// Capability files this generator wrote earlier but no longer produces.
169fn stale_capabilities(manifest: &Manifest, tauri_directory: &Path) -> Result<Vec<PathBuf>, String> {
170    let capabilities = tauri_directory.join("capabilities");
171    if !capabilities.exists() {
172        return Ok(Vec::new());
173    }
174
175    let current: Vec<String> = Capability::from_manifest(manifest)
176        .iter()
177        .map(Capability::file_name)
178        .collect();
179
180    let mut stale = Vec::new();
181    let entries = std::fs::read_dir(&capabilities)
182        .map_err(|error| format!("cannot read {}: {error}", capabilities.display()))?;
183
184    for entry in entries.filter_map(Result::ok) {
185        let path = entry.path();
186        let name = path
187            .file_name()
188            .unwrap_or_default()
189            .to_string_lossy()
190            .into_owned();
191
192        if path.extension().is_none_or(|extension| extension != "json") || current.contains(&name) {
193            continue;
194        }
195
196        // Only files this generator recognises as its own are ever removed. A
197        // hand-written capability keeps working until someone converts it.
198        let contents = std::fs::read_to_string(&path).unwrap_or_default();
199        if contents.contains(MARKER) {
200            stale.push(path);
201        }
202    }
203
204    Ok(stale)
205}
206
207fn remove_stale_capabilities(manifest: &Manifest, tauri_directory: &Path) -> Result<(), String> {
208    for path in stale_capabilities(manifest, tauri_directory)? {
209        std::fs::remove_file(&path)
210            .map_err(|error| format!("cannot remove {}: {error}", path.display()))?;
211        println!("removed stale {}", path.display());
212    }
213    Ok(())
214}
215
216/// Write only when the content changed, so an unchanged run leaves timestamps alone
217/// and does not trigger a rebuild.
218fn write_if_changed(path: &Path, contents: &str) -> Result<(), String> {
219    if std::fs::read_to_string(path).is_ok_and(|existing| existing == contents) {
220        return Ok(());
221    }
222
223    if let Some(parent) = path.parent() {
224        std::fs::create_dir_all(parent)
225            .map_err(|error| format!("cannot create {}: {error}", parent.display()))?;
226    }
227
228    std::fs::write(path, contents)
229        .map_err(|error| format!("cannot write {}: {error}", path.display()))
230}
231
232fn load(path: &Path) -> Result<Manifest, String> {
233    Manifest::load(path).map_err(|error| error.to_string())
234}
235
236/// The `src-tauri` directory belonging to a manifest.
237fn tauri_directory(manifest: &Path) -> PathBuf {
238    manifest
239        .parent()
240        .unwrap_or(Path::new("."))
241        .join("src-tauri")
242}
243
244/// Every `app.toml` in the workspace.
245///
246/// A derivative has one at its root; Origin's own repository has one per example.
247pub(crate) fn find_manifests(root: &Path) -> Result<Vec<PathBuf>, String> {
248    let mut manifests = Vec::new();
249    collect_manifests(root, &mut manifests)?;
250    manifests.sort();
251    Ok(manifests)
252}
253
254fn collect_manifests(directory: &Path, found: &mut Vec<PathBuf>) -> Result<(), String> {
255    let manifest = directory.join("app.toml");
256    if manifest.is_file() {
257        found.push(manifest);
258    }
259
260    let entries = std::fs::read_dir(directory)
261        .map_err(|error| format!("cannot read {}: {error}", directory.display()))?;
262
263    for entry in entries.filter_map(Result::ok) {
264        let path = entry.path();
265        if !path.is_dir() {
266            continue;
267        }
268
269        let name = path
270            .file_name()
271            .unwrap_or_default()
272            .to_string_lossy()
273            .into_owned();
274        // `fixtures` holds projects frozen at old versions, `templates` holds
275        // placeholders — neither is a project this workspace maintains.
276        if matches!(
277            name.as_str(),
278            "target" | "node_modules" | "dist" | "gen" | ".git" | "plan" | "fixtures" | "templates"
279        ) {
280            continue;
281        }
282
283        collect_manifests(&path, found)?;
284    }
285
286    Ok(())
287}
288
289fn relative(root: &Path, path: &Path) -> String {
290    path.strip_prefix(root)
291        .unwrap_or(path)
292        .to_string_lossy()
293        .into_owned()
294}