Skip to main content

max / makenotwork

13.1 KB · 342 lines History Blame Raw
1 //! What version this release is, read from the repository rather than taken on
2 //! trust, and the drift check between the places it is written down.
3
4 use super::git::expand_tilde;
5 use crate::domain::Version;
6 use anyhow::{Context as _, Result};
7
8 /// Read the app's version from its checkout on the daemon host. With
9 /// `version_path` set (topology `version_path`), read exactly that file — a
10 /// `.json` as a Tauri config, anything else as a `Cargo.toml`. Unset (the Tauri
11 /// default), try `src-tauri/tauri.conf.json` then the root `Cargo.toml`. Used by
12 /// the runner's default-version path.
13 pub fn version_from_repo(repo: &str, version_path: Option<&str>) -> Result<Version> {
14 let root = expand_tilde(repo);
15 if let Some(vp) = version_path {
16 let path = root.join(vp);
17 let raw = std::fs::read_to_string(&path)
18 .with_context(|| format!("reading version file {}", path.display()))?;
19 let ver = if std::path::Path::new(vp)
20 .extension()
21 .is_some_and(|e| e.eq_ignore_ascii_case("json"))
22 {
23 version_from_tauri_json(&raw)?
24 } else {
25 version_from_cargo_toml(&raw)?
26 };
27 return Version::parse(&ver).map_err(|e| anyhow::anyhow!(e));
28 }
29 let tauri_conf = root.join("src-tauri").join("tauri.conf.json");
30 if tauri_conf.exists() {
31 let raw = std::fs::read_to_string(&tauri_conf)
32 .with_context(|| format!("reading {}", tauri_conf.display()))?;
33 return Version::parse(&version_from_tauri_json(&raw)?).map_err(|e| anyhow::anyhow!(e));
34 }
35 let cargo_toml = root.join("Cargo.toml");
36 let raw = std::fs::read_to_string(&cargo_toml).with_context(|| {
37 format!(
38 "reading {} (no tauri.conf.json either)",
39 cargo_toml.display()
40 )
41 })?;
42 Version::parse(&version_from_cargo_toml(&raw)?).map_err(|e| anyhow::anyhow!(e))
43 }
44
45 /// Extract `version` from raw `tauri.conf.json` text.
46 fn version_from_tauri_json(raw: &str) -> Result<String> {
47 let v: serde_json::Value = serde_json::from_str(raw).context("parsing tauri.conf.json")?;
48 v.get("version")
49 .and_then(|x| x.as_str())
50 .map(str::to_owned)
51 .context("no `version` in tauri.conf.json")
52 }
53
54 /// Extract the version from raw `Cargo.toml` text — `[package].version` (a leaf
55 /// crate) or `[workspace.package].version` (a workspace that sets it).
56 fn version_from_cargo_toml(raw: &str) -> Result<String> {
57 let doc: toml::Value = toml::from_str(raw).context("parsing Cargo.toml")?;
58 doc.get("package")
59 .and_then(|p| p.get("version"))
60 .or_else(|| {
61 doc.get("workspace")
62 .and_then(|w| w.get("package"))
63 .and_then(|p| p.get("version"))
64 })
65 .and_then(|v| v.as_str())
66 .map(str::to_owned)
67 .context("no `[package].version` or `[workspace.package].version` in Cargo.toml")
68 }
69
70 /// Cross-check every version source in a repo and confirm they all agree with
71 /// the version being built, before a single host pulls or compiles.
72 ///
73 /// `version_from_repo` reads exactly one file, so a `tauri.conf.json` at 0.5.0
74 /// and a root `Cargo.toml` still at 0.4.0 build happily and file artifacts under
75 /// whichever the runner happened to read. This reads every source present —
76 /// `version_path` (when set), `src-tauri/tauri.conf.json`, and the root
77 /// `Cargo.toml` — and fails loudly when any disagree, naming each file and its
78 /// version. A source that is absent is skipped (a library crate with only a
79 /// `Cargo.toml` has nothing to disagree with); the check never invents drift.
80 ///
81 /// Scope: the JSON/TOML sources bentod itself reads. The iOS `gen/apple/project.yml`
82 /// path (rewritten by a build-time `sed`) is out of scope here — it is asserted at
83 /// its own build step — but the same drift class motivated this guard.
84 pub fn check_version_consistency(
85 repo: &str,
86 version_path: Option<&str>,
87 expected: &Version,
88 ) -> Result<()> {
89 let root = expand_tilde(repo);
90 let mut sources: Vec<(String, String)> = Vec::new();
91 for rel in version_sources(version_path) {
92 let path = root.join(&rel);
93 // A source that is absent is skipped — a library crate with only a
94 // `Cargo.toml` has nothing to disagree with — but one the app NAMES
95 // must be readable, or the check would pass by failing to look.
96 match std::fs::read_to_string(&path) {
97 Ok(raw) => sources.push((rel, raw)),
98 Err(e) if version_path == Some(rel.as_str()) => {
99 return Err(e).with_context(|| format!("reading version file {}", path.display()));
100 }
101 Err(_) => {}
102 }
103 }
104 versions_agree(repo, &sources, version_path, expected)
105 }
106
107 /// The files a repo can state its version in, in the order they are read:
108 /// whatever the app names, then the two conventional ones.
109 ///
110 /// The app's own `version_path` is never read twice, which is why this is a
111 /// function rather than a constant.
112 pub fn version_sources(version_path: Option<&str>) -> Vec<String> {
113 let mut rels: Vec<String> = version_path.into_iter().map(str::to_string).collect();
114 for conventional in ["src-tauri/tauri.conf.json", "Cargo.toml"] {
115 if version_path != Some(conventional) {
116 rels.push(conventional.to_string());
117 }
118 }
119 rels
120 }
121
122 /// The judgement half of [`check_version_consistency`], over sources somebody
123 /// else read.
124 ///
125 /// Split out so the same rule can be applied to files read out of the release
126 /// TAG on a build host, which is where the question actually belongs: the tree
127 /// a release compiles is the tag's, so a `Cargo.toml` that disagrees with the
128 /// tag it is tagged in is the drift worth refusing. Reading the working copy
129 /// instead answered a question about a tree the release does not build.
130 ///
131 /// `where_` is only for the error message — a path, or a tag and a host.
132 pub fn versions_agree(
133 where_: &str,
134 sources: &[(String, String)],
135 version_path: Option<&str>,
136 expected: &Version,
137 ) -> Result<()> {
138 let mut found: Vec<(String, Version)> = Vec::new();
139 for (rel, raw) in sources {
140 // The app's own `version_path` can be either shape, so it is decided by
141 // extension; the two conventional sources are what they are.
142 let as_json = std::path::Path::new(rel)
143 .extension()
144 .is_some_and(|e| e.eq_ignore_ascii_case("json"));
145 let ver = if as_json {
146 version_from_tauri_json(raw)
147 } else {
148 // A Cargo.toml with neither `[package].version` nor
149 // `[workspace.package].version` (a pure virtual workspace) carries
150 // no version to check — skip it rather than fail. An app that NAMED
151 // this file is held to it.
152 match version_from_cargo_toml(raw) {
153 Ok(v) => Ok(v),
154 Err(e) if version_path == Some(rel.as_str()) => Err(e),
155 Err(_) => continue,
156 }
157 }?;
158 found.push((
159 rel.clone(),
160 Version::parse(&ver).map_err(|e| anyhow::anyhow!(e))?,
161 ));
162 }
163
164 let disagree: Vec<&(String, Version)> = found.iter().filter(|(_, v)| v != expected).collect();
165 anyhow::ensure!(
166 disagree.is_empty(),
167 "version drift in {where_}: building {expected} but {}",
168 disagree
169 .iter()
170 .map(|(src, v)| format!("{src} says {v}"))
171 .collect::<Vec<_>>()
172 .join(", ")
173 );
174 Ok(())
175 }
176
177 /// Read one file as it exists in `tag`, without checking anything out.
178 ///
179 /// `<rev>:./<path>` resolves the path relative to `-C`, so this is asked from
180 /// the app's own directory and needs no knowledge of where that sits inside the
181 /// repository. A non-zero exit means the file is not in the tag, which is the
182 /// same "absent, so nothing to disagree with" the local read treats it as.
183 pub fn git_show_file_cmd(dir: &str, tag: &str, rel: &str) -> String {
184 format!("git -C \"{dir}\" show \"{tag}:./{rel}\"")
185 }
186
187 #[cfg(test)]
188 mod tests {
189 use super::*;
190
191 #[test]
192 fn version_from_tauri_json_reads_version() {
193 assert_eq!(
194 version_from_tauri_json(r#"{"version":"0.4.2"}"#).unwrap(),
195 "0.4.2"
196 );
197 assert!(version_from_tauri_json(r#"{"productName":"X"}"#).is_err());
198 }
199
200 #[test]
201 fn version_from_cargo_toml_prefers_package_then_workspace() {
202 // A leaf crate's [package].version.
203 assert_eq!(
204 version_from_cargo_toml("[package]\nname = \"x\"\nversion = \"0.5.0\"\n").unwrap(),
205 "0.5.0"
206 );
207 // A workspace that sets [workspace.package].version.
208 assert_eq!(
209 version_from_cargo_toml("[workspace.package]\nversion = \"1.2.3\"\n").unwrap(),
210 "1.2.3"
211 );
212 // No version anywhere -> error, not a panic.
213 assert!(version_from_cargo_toml("[workspace]\nmembers = []\n").is_err());
214 }
215
216 #[test]
217 fn version_from_repo_default_and_explicit_paths() {
218 let tmp = tempfile::tempdir().unwrap();
219 let root = tmp.path();
220
221 // Tauri app: default path reads src-tauri/tauri.conf.json.
222 let tauri = root.join("tauri");
223 std::fs::create_dir_all(tauri.join("src-tauri")).unwrap();
224 std::fs::write(
225 tauri.join("src-tauri/tauri.conf.json"),
226 r#"{"version":"0.4.2"}"#,
227 )
228 .unwrap();
229 assert_eq!(
230 version_from_repo(tauri.to_str().unwrap(), None)
231 .unwrap()
232 .to_string(),
233 "0.4.2"
234 );
235
236 // Workspace egui app: no tauri.conf.json, explicit version_path at a member crate.
237 let ws = root.join("ws");
238 std::fs::create_dir_all(ws.join("crates/app")).unwrap();
239 std::fs::write(
240 ws.join("Cargo.toml"),
241 "[workspace]\nmembers = [\"crates/app\"]\n",
242 )
243 .unwrap();
244 std::fs::write(
245 ws.join("crates/app/Cargo.toml"),
246 "[package]\nname = \"app\"\nversion = \"0.5.0\"\n",
247 )
248 .unwrap();
249 assert_eq!(
250 version_from_repo(ws.to_str().unwrap(), Some("crates/app/Cargo.toml"))
251 .unwrap()
252 .to_string(),
253 "0.5.0"
254 );
255 }
256
257 fn ver(s: &str) -> Version {
258 Version::parse(s).unwrap()
259 }
260
261 #[test]
262 fn version_consistency_passes_when_all_sources_agree() {
263 let tmp = tempfile::tempdir().unwrap();
264 let repo = tmp.path();
265 std::fs::create_dir_all(repo.join("src-tauri")).unwrap();
266 std::fs::write(
267 repo.join("src-tauri/tauri.conf.json"),
268 r#"{"version":"0.5.0"}"#,
269 )
270 .unwrap();
271 std::fs::write(
272 repo.join("Cargo.toml"),
273 "[package]\nname = \"app\"\nversion = \"0.5.0\"\n",
274 )
275 .unwrap();
276 check_version_consistency(repo.to_str().unwrap(), None, &ver("0.5.0")).unwrap();
277 }
278
279 #[test]
280 fn version_consistency_flags_tauri_vs_cargo_drift() {
281 // The concrete finding: tauri.conf.json bumped to 0.5.0 but the root
282 // Cargo.toml left at 0.4.0. version_from_repo (one file) would miss it.
283 let tmp = tempfile::tempdir().unwrap();
284 let repo = tmp.path();
285 std::fs::create_dir_all(repo.join("src-tauri")).unwrap();
286 std::fs::write(
287 repo.join("src-tauri/tauri.conf.json"),
288 r#"{"version":"0.5.0"}"#,
289 )
290 .unwrap();
291 std::fs::write(
292 repo.join("Cargo.toml"),
293 "[package]\nname = \"app\"\nversion = \"0.4.0\"\n",
294 )
295 .unwrap();
296 let err =
297 check_version_consistency(repo.to_str().unwrap(), None, &ver("0.5.0")).unwrap_err();
298 let msg = format!("{err:#}");
299 assert!(msg.contains("Cargo.toml says 0.4.0"), "{msg}");
300 }
301
302 #[test]
303 fn version_consistency_flags_explicit_version_the_repo_does_not_reflect() {
304 let tmp = tempfile::tempdir().unwrap();
305 let repo = tmp.path();
306 std::fs::create_dir_all(repo.join("src-tauri")).unwrap();
307 std::fs::write(
308 repo.join("src-tauri/tauri.conf.json"),
309 r#"{"version":"0.5.0"}"#,
310 )
311 .unwrap();
312 let err =
313 check_version_consistency(repo.to_str().unwrap(), None, &ver("9.9.9")).unwrap_err();
314 assert!(format!("{err:#}").contains("building 9.9.9"));
315 }
316
317 #[test]
318 fn version_consistency_single_source_never_invents_drift() {
319 // A virtual-workspace root Cargo.toml (no version) alongside the member
320 // crate the version_path points at: only one real source, so no drift.
321 let tmp = tempfile::tempdir().unwrap();
322 let repo = tmp.path();
323 std::fs::create_dir_all(repo.join("crates/app")).unwrap();
324 std::fs::write(
325 repo.join("Cargo.toml"),
326 "[workspace]\nmembers = [\"crates/app\"]\n",
327 )
328 .unwrap();
329 std::fs::write(
330 repo.join("crates/app/Cargo.toml"),
331 "[package]\nname = \"app\"\nversion = \"0.5.0\"\n",
332 )
333 .unwrap();
334 check_version_consistency(
335 repo.to_str().unwrap(),
336 Some("crates/app/Cargo.toml"),
337 &ver("0.5.0"),
338 )
339 .unwrap();
340 }
341 }
342