//! What version this release is, read from the repository rather than taken on //! trust, and the drift check between the places it is written down. use super::git::expand_tilde; use crate::domain::Version; use anyhow::{Context as _, Result}; /// Read the app's version from its checkout on the daemon host. With /// `version_path` set (topology `version_path`), read exactly that file — a /// `.json` as a Tauri config, anything else as a `Cargo.toml`. Unset (the Tauri /// default), try `src-tauri/tauri.conf.json` then the root `Cargo.toml`. Used by /// the runner's default-version path. pub fn version_from_repo(repo: &str, version_path: Option<&str>) -> Result { let root = expand_tilde(repo); if let Some(vp) = version_path { let path = root.join(vp); let raw = std::fs::read_to_string(&path) .with_context(|| format!("reading version file {}", path.display()))?; let ver = if std::path::Path::new(vp) .extension() .is_some_and(|e| e.eq_ignore_ascii_case("json")) { version_from_tauri_json(&raw)? } else { version_from_cargo_toml(&raw)? }; return Version::parse(&ver).map_err(|e| anyhow::anyhow!(e)); } let tauri_conf = root.join("src-tauri").join("tauri.conf.json"); if tauri_conf.exists() { let raw = std::fs::read_to_string(&tauri_conf) .with_context(|| format!("reading {}", tauri_conf.display()))?; return Version::parse(&version_from_tauri_json(&raw)?).map_err(|e| anyhow::anyhow!(e)); } let cargo_toml = root.join("Cargo.toml"); let raw = std::fs::read_to_string(&cargo_toml).with_context(|| { format!( "reading {} (no tauri.conf.json either)", cargo_toml.display() ) })?; Version::parse(&version_from_cargo_toml(&raw)?).map_err(|e| anyhow::anyhow!(e)) } /// Extract `version` from raw `tauri.conf.json` text. fn version_from_tauri_json(raw: &str) -> Result { let v: serde_json::Value = serde_json::from_str(raw).context("parsing tauri.conf.json")?; v.get("version") .and_then(|x| x.as_str()) .map(str::to_owned) .context("no `version` in tauri.conf.json") } /// Extract the version from raw `Cargo.toml` text — `[package].version` (a leaf /// crate) or `[workspace.package].version` (a workspace that sets it). fn version_from_cargo_toml(raw: &str) -> Result { let doc: toml::Value = toml::from_str(raw).context("parsing Cargo.toml")?; doc.get("package") .and_then(|p| p.get("version")) .or_else(|| { doc.get("workspace") .and_then(|w| w.get("package")) .and_then(|p| p.get("version")) }) .and_then(|v| v.as_str()) .map(str::to_owned) .context("no `[package].version` or `[workspace.package].version` in Cargo.toml") } /// Cross-check every version source in a repo and confirm they all agree with /// the version being built, before a single host pulls or compiles. /// /// `version_from_repo` reads exactly one file, so a `tauri.conf.json` at 0.5.0 /// and a root `Cargo.toml` still at 0.4.0 build happily and file artifacts under /// whichever the runner happened to read. This reads every source present — /// `version_path` (when set), `src-tauri/tauri.conf.json`, and the root /// `Cargo.toml` — and fails loudly when any disagree, naming each file and its /// version. A source that is absent is skipped (a library crate with only a /// `Cargo.toml` has nothing to disagree with); the check never invents drift. /// /// Scope: the JSON/TOML sources bentod itself reads. The iOS `gen/apple/project.yml` /// path (rewritten by a build-time `sed`) is out of scope here — it is asserted at /// its own build step — but the same drift class motivated this guard. pub fn check_version_consistency( repo: &str, version_path: Option<&str>, expected: &Version, ) -> Result<()> { let root = expand_tilde(repo); let mut sources: Vec<(String, String)> = Vec::new(); for rel in version_sources(version_path) { let path = root.join(&rel); // A source that is absent is skipped — a library crate with only a // `Cargo.toml` has nothing to disagree with — but one the app NAMES // must be readable, or the check would pass by failing to look. match std::fs::read_to_string(&path) { Ok(raw) => sources.push((rel, raw)), Err(e) if version_path == Some(rel.as_str()) => { return Err(e).with_context(|| format!("reading version file {}", path.display())); } Err(_) => {} } } versions_agree(repo, &sources, version_path, expected) } /// The files a repo can state its version in, in the order they are read: /// whatever the app names, then the two conventional ones. /// /// The app's own `version_path` is never read twice, which is why this is a /// function rather than a constant. pub fn version_sources(version_path: Option<&str>) -> Vec { let mut rels: Vec = version_path.into_iter().map(str::to_string).collect(); for conventional in ["src-tauri/tauri.conf.json", "Cargo.toml"] { if version_path != Some(conventional) { rels.push(conventional.to_string()); } } rels } /// The judgement half of [`check_version_consistency`], over sources somebody /// else read. /// /// Split out so the same rule can be applied to files read out of the release /// TAG on a build host, which is where the question actually belongs: the tree /// a release compiles is the tag's, so a `Cargo.toml` that disagrees with the /// tag it is tagged in is the drift worth refusing. Reading the working copy /// instead answered a question about a tree the release does not build. /// /// `where_` is only for the error message — a path, or a tag and a host. pub fn versions_agree( where_: &str, sources: &[(String, String)], version_path: Option<&str>, expected: &Version, ) -> Result<()> { let mut found: Vec<(String, Version)> = Vec::new(); for (rel, raw) in sources { // The app's own `version_path` can be either shape, so it is decided by // extension; the two conventional sources are what they are. let as_json = std::path::Path::new(rel) .extension() .is_some_and(|e| e.eq_ignore_ascii_case("json")); let ver = if as_json { version_from_tauri_json(raw) } else { // A Cargo.toml with neither `[package].version` nor // `[workspace.package].version` (a pure virtual workspace) carries // no version to check — skip it rather than fail. An app that NAMED // this file is held to it. match version_from_cargo_toml(raw) { Ok(v) => Ok(v), Err(e) if version_path == Some(rel.as_str()) => Err(e), Err(_) => continue, } }?; found.push(( rel.clone(), Version::parse(&ver).map_err(|e| anyhow::anyhow!(e))?, )); } let disagree: Vec<&(String, Version)> = found.iter().filter(|(_, v)| v != expected).collect(); anyhow::ensure!( disagree.is_empty(), "version drift in {where_}: building {expected} but {}", disagree .iter() .map(|(src, v)| format!("{src} says {v}")) .collect::>() .join(", ") ); Ok(()) } /// Read one file as it exists in `tag`, without checking anything out. /// /// `:./` resolves the path relative to `-C`, so this is asked from /// the app's own directory and needs no knowledge of where that sits inside the /// repository. A non-zero exit means the file is not in the tag, which is the /// same "absent, so nothing to disagree with" the local read treats it as. pub fn git_show_file_cmd(dir: &str, tag: &str, rel: &str) -> String { format!("git -C \"{dir}\" show \"{tag}:./{rel}\"") } #[cfg(test)] mod tests { use super::*; #[test] fn version_from_tauri_json_reads_version() { assert_eq!( version_from_tauri_json(r#"{"version":"0.4.2"}"#).unwrap(), "0.4.2" ); assert!(version_from_tauri_json(r#"{"productName":"X"}"#).is_err()); } #[test] fn version_from_cargo_toml_prefers_package_then_workspace() { // A leaf crate's [package].version. assert_eq!( version_from_cargo_toml("[package]\nname = \"x\"\nversion = \"0.5.0\"\n").unwrap(), "0.5.0" ); // A workspace that sets [workspace.package].version. assert_eq!( version_from_cargo_toml("[workspace.package]\nversion = \"1.2.3\"\n").unwrap(), "1.2.3" ); // No version anywhere -> error, not a panic. assert!(version_from_cargo_toml("[workspace]\nmembers = []\n").is_err()); } #[test] fn version_from_repo_default_and_explicit_paths() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); // Tauri app: default path reads src-tauri/tauri.conf.json. let tauri = root.join("tauri"); std::fs::create_dir_all(tauri.join("src-tauri")).unwrap(); std::fs::write( tauri.join("src-tauri/tauri.conf.json"), r#"{"version":"0.4.2"}"#, ) .unwrap(); assert_eq!( version_from_repo(tauri.to_str().unwrap(), None) .unwrap() .to_string(), "0.4.2" ); // Workspace egui app: no tauri.conf.json, explicit version_path at a member crate. let ws = root.join("ws"); std::fs::create_dir_all(ws.join("crates/app")).unwrap(); std::fs::write( ws.join("Cargo.toml"), "[workspace]\nmembers = [\"crates/app\"]\n", ) .unwrap(); std::fs::write( ws.join("crates/app/Cargo.toml"), "[package]\nname = \"app\"\nversion = \"0.5.0\"\n", ) .unwrap(); assert_eq!( version_from_repo(ws.to_str().unwrap(), Some("crates/app/Cargo.toml")) .unwrap() .to_string(), "0.5.0" ); } fn ver(s: &str) -> Version { Version::parse(s).unwrap() } #[test] fn version_consistency_passes_when_all_sources_agree() { let tmp = tempfile::tempdir().unwrap(); let repo = tmp.path(); std::fs::create_dir_all(repo.join("src-tauri")).unwrap(); std::fs::write( repo.join("src-tauri/tauri.conf.json"), r#"{"version":"0.5.0"}"#, ) .unwrap(); std::fs::write( repo.join("Cargo.toml"), "[package]\nname = \"app\"\nversion = \"0.5.0\"\n", ) .unwrap(); check_version_consistency(repo.to_str().unwrap(), None, &ver("0.5.0")).unwrap(); } #[test] fn version_consistency_flags_tauri_vs_cargo_drift() { // The concrete finding: tauri.conf.json bumped to 0.5.0 but the root // Cargo.toml left at 0.4.0. version_from_repo (one file) would miss it. let tmp = tempfile::tempdir().unwrap(); let repo = tmp.path(); std::fs::create_dir_all(repo.join("src-tauri")).unwrap(); std::fs::write( repo.join("src-tauri/tauri.conf.json"), r#"{"version":"0.5.0"}"#, ) .unwrap(); std::fs::write( repo.join("Cargo.toml"), "[package]\nname = \"app\"\nversion = \"0.4.0\"\n", ) .unwrap(); let err = check_version_consistency(repo.to_str().unwrap(), None, &ver("0.5.0")).unwrap_err(); let msg = format!("{err:#}"); assert!(msg.contains("Cargo.toml says 0.4.0"), "{msg}"); } #[test] fn version_consistency_flags_explicit_version_the_repo_does_not_reflect() { let tmp = tempfile::tempdir().unwrap(); let repo = tmp.path(); std::fs::create_dir_all(repo.join("src-tauri")).unwrap(); std::fs::write( repo.join("src-tauri/tauri.conf.json"), r#"{"version":"0.5.0"}"#, ) .unwrap(); let err = check_version_consistency(repo.to_str().unwrap(), None, &ver("9.9.9")).unwrap_err(); assert!(format!("{err:#}").contains("building 9.9.9")); } #[test] fn version_consistency_single_source_never_invents_drift() { // A virtual-workspace root Cargo.toml (no version) alongside the member // crate the version_path points at: only one real source, so no drift. let tmp = tempfile::tempdir().unwrap(); let repo = tmp.path(); std::fs::create_dir_all(repo.join("crates/app")).unwrap(); std::fs::write( repo.join("Cargo.toml"), "[workspace]\nmembers = [\"crates/app\"]\n", ) .unwrap(); std::fs::write( repo.join("crates/app/Cargo.toml"), "[package]\nname = \"app\"\nversion = \"0.5.0\"\n", ) .unwrap(); check_version_consistency( repo.to_str().unwrap(), Some("crates/app/Cargo.toml"), &ver("0.5.0"), ) .unwrap(); } }