pub mod sha256 {
include!("sha256.rs");
}
pub mod elf {
include!("elf.rs");
}
use sha256::{checksum_from_sha256_file, sha256_file};
use std::path::{Path, PathBuf};
pub const RENDERER_DIR_ENV: &str = "COMPOSE_RUST_RENDERER_DIR";
pub const CACHE_DIR_ENV: &str = "COMPOSE_RUST_CACHE_DIR";
pub const RELEASE_BASE_URL: &str = "https://github.com/DarkPyonix/compose-rust/releases";
pub const PUBLISHED_TARGETS: &[&str] = &["macos-aarch64", "windows-x64", "linux-x64", "linux-arm64"];
pub fn renderer_lib_file(target_os: &str) -> &'static str {
match target_os {
"windows" => "libcompose_rust_renderer.dll",
"macos" => "libcompose_rust_renderer.dylib",
_ => "libcompose_rust_renderer.so",
}
}
pub fn renderer_lib_subdir(target_os: &str) -> &'static str {
match target_os {
"windows" => "bin",
_ => "lib",
}
}
pub const RENDERER_VERSION_FILE: &str = "renderer.version";
pub fn artifact_file_name(crate_version: &str, target: &str) -> String {
format!("compose-rust-renderer-v{crate_version}-{target}.tar.gz")
}
pub fn artifact_url(crate_version: &str, file_name: &str) -> String {
format!("{RELEASE_BASE_URL}/download/v{crate_version}/{file_name}")
}
pub fn artifact_target(target_os: &str, target_arch: &str) -> String {
let arch = match (target_os, target_arch) {
("macos", "aarch64") => "aarch64",
(_, "aarch64") => "arm64",
(_, "x86_64") => "x64",
_ => target_arch,
};
format!("{target_os}-{arch}")
}
pub fn default_cache_root(
home: Option<&Path>,
local_app_data: Option<&Path>,
host_is_windows: bool,
) -> Option<PathBuf> {
if host_is_windows {
return local_app_data.map(|dir| dir.join("compose-rust"));
}
home.map(|dir| dir.join(".cache").join("compose-rust"))
}
pub fn cached_renderer_dir(cache_root: &Path, crate_version: &str, target: &str) -> PathBuf {
cache_root
.join("renderer")
.join(format!("v{crate_version}"))
.join(target)
}
pub fn download_dir(cache_root: &Path) -> PathBuf {
cache_root.join("downloads")
}
#[derive(Debug)]
pub struct Renderer {
pub lib_dir: PathBuf,
pub artifact_version: Option<String>,
pub source: RendererSource,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum RendererSource {
Environment,
Workspace,
Cache,
LocalArtifact,
Download,
}
pub type Fetch<'a> = &'a dyn Fn(&str, &Path) -> Result<(), FetchError>;
#[derive(Debug)]
pub enum FetchError {
Unreachable(String),
NotFound,
Failed(String),
}
pub struct Request<'a> {
pub env_dir: Option<&'a Path>,
pub workspace_lib_dir: &'a Path,
pub cache_root: Option<&'a Path>,
pub crate_version: &'a str,
pub target: &'a str,
pub target_os: &'a str,
pub fetch: Option<Fetch<'a>>,
}
pub fn acquire_renderer(request: &Request) -> Result<Renderer, String> {
let lib_file = renderer_lib_file(request.target_os);
if let Some(dir) = request.env_dir {
let lib_dir = lib_dir_within(dir, lib_file);
if !lib_dir.join(lib_file).is_file() {
return Err(missing_from_env_message(dir, &lib_dir, lib_file));
}
return finish(lib_dir, request, RendererSource::Environment);
}
if request.workspace_lib_dir.join(lib_file).is_file() {
return finish(
request.workspace_lib_dir.to_path_buf(),
request,
RendererSource::Workspace,
);
}
let Some(cache_root) = request.cache_root else {
return Err(no_cache_root_message(request.crate_version, request.target));
};
let cached = cached_renderer_dir(cache_root, request.crate_version, request.target);
let cached_lib_dir = cached.join(renderer_lib_subdir(request.target_os));
if cached_lib_dir.join(lib_file).is_file() {
return finish(cached_lib_dir, request, RendererSource::Cache);
}
if !PUBLISHED_TARGETS.contains(&request.target) {
return Err(unpublished_target_message(
request.crate_version,
request.target,
));
}
let source = fetch_artifact(request, cache_root)?;
let artifact = artifact_file_name(request.crate_version, request.target);
let tarball = download_dir(cache_root).join(&artifact);
verify_checksum(&tarball, cache_root)?;
unpack(
&tarball,
&cached,
request.target_os,
request.crate_version,
request.target,
)?;
if !cached_lib_dir.join(lib_file).is_file() {
return Err(unexpected_layout_message(
&tarball,
&cached,
lib_file,
renderer_lib_subdir(request.target_os),
));
}
finish(cached_lib_dir, request, source)
}
fn fetch_artifact(request: &Request, cache_root: &Path) -> Result<RendererSource, String> {
let artifact = artifact_file_name(request.crate_version, request.target);
let checksum_name = format!("{artifact}.sha256");
let downloads = download_dir(cache_root);
let tarball = downloads.join(&artifact);
let checksum = downloads.join(&checksum_name);
if tarball.is_file() && checksum.is_file() {
return Ok(RendererSource::LocalArtifact);
}
let Some(fetch) = request.fetch else {
return Err(offline_message(
request.crate_version,
request.target,
&downloads,
"this build was told not to use the network",
));
};
std::fs::create_dir_all(&downloads)
.map_err(|error| cannot_write_message(&downloads, &error.to_string()))?;
for (name, destination) in [(&artifact, &tarball), (&checksum_name, &checksum)] {
if destination.is_file() {
continue;
}
let url = artifact_url(request.crate_version, name);
let partial = destination.with_extension("partial");
let _ = std::fs::remove_file(&partial);
match fetch(&url, &partial) {
Ok(()) => std::fs::rename(&partial, destination)
.map_err(|error| cannot_write_message(destination, &error.to_string()))?,
Err(error) => {
let _ = std::fs::remove_file(&partial);
return Err(match error {
FetchError::Unreachable(detail) => offline_message(
request.crate_version,
request.target,
&downloads,
&detail,
),
FetchError::NotFound => {
not_in_release_message(request.crate_version, request.target, name)
}
FetchError::Failed(detail) => download_failed_message(&url, &detail),
});
}
}
}
Ok(RendererSource::Download)
}
fn verify_checksum(tarball: &Path, cache_root: &Path) -> Result<(), String> {
let checksum_path = sibling_checksum(tarball);
let contents = std::fs::read_to_string(&checksum_path)
.map_err(|error| unreadable_checksum_message(&checksum_path, &error.to_string()))?;
let Some(expected) = checksum_from_sha256_file(&contents) else {
return Err(unreadable_checksum_message(
&checksum_path,
"it does not start with a 64 character hex digest",
));
};
let actual = sha256_file(tarball)
.map_err(|error| cannot_read_artifact_message(tarball, &error.to_string()))?;
if actual != expected {
return Err(checksum_mismatch_message(
tarball,
&checksum_path,
&expected,
&actual,
&download_dir(cache_root),
));
}
Ok(())
}
pub fn sibling_checksum(tarball: &Path) -> PathBuf {
let mut name = tarball.file_name().unwrap_or_default().to_os_string();
name.push(".sha256");
tarball.with_file_name(name)
}
fn unpack(
tarball: &Path,
destination: &Path,
target_os: &str,
crate_version: &str,
target: &str,
) -> Result<(), String> {
let parent = destination
.parent()
.ok_or_else(|| format!("the renderer cache path {} has no parent", destination.display()))?;
std::fs::create_dir_all(parent)
.map_err(|error| cannot_write_message(parent, &error.to_string()))?;
let scratch = parent.join(format!(
".unpacking-{}-{}",
destination.file_name().unwrap_or_default().to_string_lossy(),
std::process::id()
));
let _ = std::fs::remove_dir_all(&scratch);
std::fs::create_dir_all(&scratch)
.map_err(|error| cannot_write_message(&scratch, &error.to_string()))?;
let status = std::process::Command::new("tar")
.arg("-xzf")
.arg(tarball)
.arg("-C")
.arg(&scratch)
.status();
let result = match status {
Ok(status) if status.success() => Ok(()),
Ok(status) => Err(unpack_failed_message(
tarball,
&format!("tar exited with {status}"),
)),
Err(error) => Err(unpack_failed_message(tarball, &error.to_string())),
};
if let Err(message) = result {
let _ = std::fs::remove_dir_all(&scratch);
return Err(message);
}
let subdir = renderer_lib_subdir(target_os);
let lib_file = renderer_lib_file(target_os);
let unpacked = scratch.join(subdir).join(lib_file);
if unpacked.is_file() {
if let Err(message) = name_after_its_location(
&unpacked,
&destination.join(subdir).join(lib_file),
target_os,
crate_version,
target,
) {
let _ = std::fs::remove_dir_all(&scratch);
return Err(message);
}
}
let _ = std::fs::remove_dir_all(destination);
std::fs::rename(&scratch, destination).map_err(|error| {
let _ = std::fs::remove_dir_all(&scratch);
cannot_write_message(destination, &error.to_string())
})
}
pub fn name_after_its_location(
library: &Path,
name: &Path,
target_os: &str,
crate_version: &str,
target: &str,
) -> Result<(), String> {
match target_os {
"macos" => set_install_name(library, name, crate_version, target),
"linux" => set_soname(library, name, crate_version, target),
_ => Ok(()),
}
}
fn set_install_name(
library: &Path,
name: &Path,
crate_version: &str,
target: &str,
) -> Result<(), String> {
if install_name(library).as_deref() == Some(name.to_string_lossy().as_ref()) {
return Ok(());
}
let status = std::process::Command::new("install_name_tool")
.arg("-id")
.arg(name)
.arg(library)
.output();
match status {
Ok(output) if output.status.success() => Ok(()),
Ok(output) => Err(install_name_message(
name,
&String::from_utf8_lossy(&output.stderr),
crate_version,
target,
)),
Err(error) => Err(install_name_message(
name,
&error.to_string(),
crate_version,
target,
)),
}
}
fn install_name(library: &Path) -> Option<String> {
let output = std::process::Command::new("otool")
.arg("-D")
.arg(library)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let name = text.lines().nth(1)?.trim();
(!name.is_empty()).then(|| name.to_string())
}
fn set_soname(library: &Path, name: &Path, crate_version: &str, target: &str) -> Result<(), String> {
let Some(text) = name.to_str() else {
return Err(soname_message(
name,
"the path is not UTF-8, and this build only writes UTF-8 names",
crate_version,
target,
));
};
elf::set_soname(library, text)
.map(|_| ())
.map_err(|detail| soname_message(name, &detail, crate_version, target))
}
fn finish(lib_dir: PathBuf, request: &Request, source: RendererSource) -> Result<Renderer, String> {
let lib_dir = absolute(&lib_dir);
let artifact_version = read_artifact_version(&lib_dir);
if let Some(found) = artifact_version.as_deref() {
if found != request.crate_version {
return Err(version_mismatch_message(
&lib_dir,
found,
request.crate_version,
request.target,
));
}
}
let library = lib_dir.join(renderer_lib_file(request.target_os));
name_after_its_location(
&library,
&library,
request.target_os,
request.crate_version,
request.target,
)?;
Ok(Renderer {
lib_dir,
artifact_version,
source,
})
}
pub fn absolute(dir: &Path) -> PathBuf {
if dir.is_absolute() {
return dir.to_path_buf();
}
match std::env::current_dir() {
Ok(working) => working.join(dir),
Err(_) => dir.to_path_buf(),
}
}
fn lib_dir_within(dir: &Path, lib_file: &str) -> PathBuf {
for subdir in ["lib", "bin"] {
let nested = dir.join(subdir);
if nested.join(lib_file).is_file() {
return nested;
}
}
dir.to_path_buf()
}
fn read_artifact_version(lib_dir: &Path) -> Option<String> {
let candidates = [
lib_dir.join(RENDERER_VERSION_FILE),
lib_dir.join("..").join(RENDERER_VERSION_FILE),
];
for candidate in candidates {
if let Ok(contents) = std::fs::read_to_string(&candidate) {
let trimmed = contents.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
None
}
fn build_it_yourself(crate_version: &str, target: &str) -> String {
let artifact = artifact_file_name(crate_version, target);
format!(
"Any renderer you already have works too. Unpack {artifact}, or build one with\n\
renderer/desktop/scripts/build-native.sh from a checkout of the\n\
repository, then set {RENDERER_DIR_ENV} to that directory. The variable is checked\n\
first and nothing is downloaded when it is set."
)
}
fn missing_from_env_message(env_dir: &Path, looked_in: &Path, lib_file: &str) -> String {
format!(
"compose-rust: {RENDERER_DIR_ENV} is set to {env_dir}, but no renderer is there.\n\
\n\
Looked for {lib_file} in {looked_in}, in {env_dir}/lib and in {env_dir}/bin. Point\n\
the variable either at the directory an artifact was unpacked into or straight at\n\
the directory holding the library.\n\
\n\
Nothing was downloaded, because the variable is set. Unset it and this build\n\
fetches the renderer for its own version by itself.",
env_dir = env_dir.display(),
looked_in = looked_in.display(),
)
}
fn unpublished_target_message(crate_version: &str, target: &str) -> String {
format!(
"compose-rust: no renderer is published for {target}.\n\
\n\
The release builds these targets: {published}. This build is for {target}, so\n\
there is nothing to download, and a download would only have produced a 404 that\n\
does not say which of the two is missing.\n\
\n\
{build_it_yourself}",
published = PUBLISHED_TARGETS.join(", "),
build_it_yourself = build_it_yourself(crate_version, target),
)
}
fn offline_message(
crate_version: &str,
target: &str,
downloads: &Path,
detail: &str,
) -> String {
let artifact = artifact_file_name(crate_version, target);
format!(
"compose-rust: could not download the renderer ({detail}).\n\
\n\
Put these two files in {downloads} and build again. Nothing else is needed, and\n\
the next build will verify the checksum and unpack them without a network:\n\
\n\
\x20 {artifact}\n\
\x20 {artifact}.sha256\n\
\n\
Both are attached to the release for this crate version:\n\
\x20 {tag_url}\n\
\n\
{build_it_yourself}",
downloads = downloads.display(),
tag_url = format_args!("{RELEASE_BASE_URL}/tag/v{crate_version}"),
build_it_yourself = build_it_yourself(crate_version, target),
)
}
fn not_in_release_message(crate_version: &str, target: &str, file_name: &str) -> String {
format!(
"compose-rust: the release for v{crate_version} does not carry {file_name}.\n\
\n\
The server was reached and answered that there is no such file, so this is not a\n\
network problem. Either that release was published without the {target} renderer,\n\
or it predates this crate version being tagged at all. What it does carry is\n\
listed at:\n\
\x20 {tag_url}\n\
\n\
{build_it_yourself}",
tag_url = format_args!("{RELEASE_BASE_URL}/tag/v{crate_version}"),
build_it_yourself = build_it_yourself(crate_version, target),
)
}
fn download_failed_message(url: &str, detail: &str) -> String {
format!(
"compose-rust: downloading the renderer failed ({detail}).\n\
\n\
The address was {url}. Retrying the build retries the download; nothing partial\n\
was kept."
)
}
fn checksum_mismatch_message(
tarball: &Path,
checksum_path: &Path,
expected: &str,
actual: &str,
downloads: &Path,
) -> String {
format!(
"compose-rust: the renderer artifact does not match its checksum, so it was not\n\
unpacked.\n\
\n\
\x20 file {tarball}\n\
\x20 expected {expected}\n\
\x20 actual {actual}\n\
\n\
{checksum_path} is what says the first of those. A download that was cut short is\n\
much the likelier cause than a tampered release, and either way the fix is the\n\
same: delete both files and build again.\n\
\n\
\x20 rm -rf {downloads}",
tarball = tarball.display(),
checksum_path = checksum_path.display(),
downloads = downloads.display(),
)
}
fn unreadable_checksum_message(checksum_path: &Path, detail: &str) -> String {
format!(
"compose-rust: cannot read the renderer checksum at {checksum_path} ({detail}).\n\
\n\
The file holds one line: the SHA-256 digest, then the artifact name, exactly as\n\
`shasum -a 256` writes it. Delete it and build again to download it afresh.",
checksum_path = checksum_path.display(),
)
}
fn cannot_read_artifact_message(tarball: &Path, detail: &str) -> String {
format!(
"compose-rust: cannot read the renderer artifact at {tarball} ({detail}).\n\
\n\
Delete it and build again to download it afresh.",
tarball = tarball.display(),
)
}
fn cannot_write_message(path: &Path, detail: &str) -> String {
format!(
"compose-rust: cannot write to {path} ({detail}).\n\
\n\
That path is the renderer cache. Set {CACHE_DIR_ENV} to a directory this build can\n\
write to, or set {RENDERER_DIR_ENV} to a renderer you already have, which skips\n\
the cache entirely.",
path = path.display(),
)
}
fn no_cache_root_message(crate_version: &str, target: &str) -> String {
format!(
"compose-rust: there is nowhere to cache the renderer.\n\
\n\
The cache normally goes under $HOME/.cache on Unix and %LOCALAPPDATA% on Windows,\n\
and neither is set for this build. Set {CACHE_DIR_ENV} to a directory to download\n\
into.\n\
\n\
{build_it_yourself}",
build_it_yourself = build_it_yourself(crate_version, target),
)
}
fn install_name_message(library: &Path, detail: &str, crate_version: &str, target: &str) -> String {
format!(
"compose-rust: could not set the renderer's install name ({detail}).\n\
\n\
The library has to be told that it lives at\n\
\n\
\x20 {library}\n\
\n\
or anything linking it records the name it came with, `@rpath/{file}`, and dyld\n\
cannot resolve that in an application that has no matching rpath. `install_name_tool`\n\
does it and comes with the Xcode command line tools, which the Rust linker needs\n\
anyway: `xcode-select --install`. A renderer in a directory this build cannot write\n\
to has to carry that name already; copy it somewhere writable and point\n\
{RENDERER_DIR_ENV} at the copy.\n\
\n\
{build_it_yourself}",
library = library.display(),
file = renderer_lib_file("macos"),
build_it_yourself = build_it_yourself(crate_version, target),
)
}
fn soname_message(library: &Path, detail: &str, crate_version: &str, target: &str) -> String {
format!(
"compose-rust: could not set the renderer's SONAME ({detail}).\n\
\n\
The library has to be told that it lives at\n\
\n\
\x20 {library}\n\
\n\
or anything linking it records its bare file name, `{file}`, and the loader cannot\n\
find that in an application that has no matching rpath. This build makes the change\n\
by editing the file, with no tool needed. `patchelf --set-soname` makes the same\n\
change and can make room where this could not. A renderer in a directory this build\n\
cannot write to has to carry that name already; copy it somewhere writable and\n\
point {RENDERER_DIR_ENV} at the copy.\n\
\n\
{build_it_yourself}",
library = library.display(),
file = renderer_lib_file("linux"),
build_it_yourself = build_it_yourself(crate_version, target),
)
}
fn unpack_failed_message(tarball: &Path, detail: &str) -> String {
format!(
"compose-rust: could not unpack the renderer artifact ({detail}).\n\
\n\
The file is {tarball} and its checksum matched, so it arrived intact. What failed\n\
was extracting it, which needs `tar` on PATH. macOS and Linux always have it, and\n\
Windows has carried it in System32 since Windows 10 1803.",
tarball = tarball.display(),
)
}
fn unexpected_layout_message(
tarball: &Path,
unpacked_into: &Path,
lib_file: &str,
subdir: &str,
) -> String {
format!(
"compose-rust: the renderer artifact unpacked, but does not contain a renderer.\n\
\n\
Expected {subdir}/{lib_file} under {unpacked_into}, from {tarball}. An artifact\n\
built for a different platform would look exactly like this. Delete that directory\n\
and the downloaded artifact, then build again.",
tarball = tarball.display(),
unpacked_into = unpacked_into.display(),
)
}
fn version_mismatch_message(
lib_dir: &Path,
artifact_version: &str,
crate_version: &str,
target: &str,
) -> String {
format!(
"compose-rust: renderer version mismatch.\n\
\n\
The renderer in {lib_dir} declares version {artifact_version} ({file}), but this\n\
crate is version {crate_version}. Linking them would pair a Host against a Renderer\n\
it was never built for, and the mismatch would surface as a protocol error at\n\
runtime instead of here.\n\
\n\
Use the artifact for this crate version, {artifact}, or depend on\n\
compose-rust {artifact_version} instead.",
lib_dir = lib_dir.display(),
file = RENDERER_VERSION_FILE,
artifact = artifact_file_name(crate_version, target),
)
}
pub fn every_failure_message(sample_dir: &Path) -> Vec<String> {
let version = "9.9.9";
let target = "macos-aarch64";
let tarball = sample_dir.join(artifact_file_name(version, target));
vec![
missing_from_env_message(sample_dir, sample_dir, renderer_lib_file("macos")),
unpublished_target_message(version, "freebsd-x86_64"),
offline_message(version, target, sample_dir, "curl could not resolve the host"),
not_in_release_message(version, target, &artifact_file_name(version, target)),
download_failed_message(&artifact_url(version, &artifact_file_name(version, target)), "HTTP 503"),
checksum_mismatch_message(&tarball, &sibling_checksum(&tarball), "aa", "bb", sample_dir),
unreadable_checksum_message(&sibling_checksum(&tarball), "no such file"),
cannot_read_artifact_message(&tarball, "permission denied"),
cannot_write_message(sample_dir, "read-only file system"),
no_cache_root_message(version, target),
unpack_failed_message(&tarball, "tar exited with 2"),
install_name_message(
&sample_dir.join(renderer_lib_file("macos")),
"not found",
version,
target,
),
soname_message(
&sample_dir.join(renderer_lib_file("linux")),
"permission denied",
version,
"linux-x64",
),
unexpected_layout_message(&tarball, sample_dir, renderer_lib_file("macos"), "lib"),
version_mismatch_message(sample_dir, "0.1.0", version, target),
build_it_yourself(version, target),
]
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RendererLinkage {
Linked,
Provided,
Installed,
None,
}
pub fn renderer_linkage(target_os: &str, target_family: &str, mock: bool) -> RendererLinkage {
if mock {
return RendererLinkage::None;
}
if target_family.split(',').any(|family| family == "wasm") {
return RendererLinkage::Installed;
}
match target_os {
"macos" | "windows" | "linux" => RendererLinkage::Linked,
"android" => RendererLinkage::Installed,
_ => RendererLinkage::Provided,
}
}
pub fn android_gradle_kotlin(
ours: Option<&str>,
ours_package: Option<&str>,
dx_dir: Option<&str>,
dx_package: Option<&str>,
) -> Option<(PathBuf, Option<String>)> {
if let Some(ours) = ours {
return Some((PathBuf::from(ours), ours_package.map(str::to_owned)));
}
let package = dx_package?;
let mut root = PathBuf::from(dx_dir?);
for _ in package.split('.') {
root = root.parent()?.to_path_buf();
}
Some((root, Some(package.to_owned())))
}
pub const ANDROID_COMPOSE_DEPENDENCIES: [&str; 4] = [
"androidx.activity:activity-compose:1.9.3",
"androidx.compose.ui:ui:1.7.5",
"androidx.compose.foundation:foundation:1.7.5",
"androidx.compose.material3:material3:1.3.1",
];
const COMPOSE_PLUGIN_ID: &str = "org.jetbrains.kotlin.plugin.compose";
pub fn with_notification_permission(manifest: &str) -> Option<String> {
const PERMISSION: &str = "android.permission.POST_NOTIFICATIONS";
if manifest.contains(PERMISSION) {
return None;
}
let open = manifest.find("<manifest")?;
let end = open + manifest[open..].find('>')? + 1;
let mut text = String::with_capacity(manifest.len() + 96);
text.push_str(&manifest[..end]);
text.push_str("\n <uses-permission android:name=\"");
text.push_str(PERMISSION);
text.push_str("\" />");
text.push_str(&manifest[end..]);
Some(text)
}
pub fn with_compose(module: &str, root: &str) -> Option<(String, Option<String>)> {
let kotlin = kotlin_gradle_plugin_version(root)?;
let mut module_text = module.to_owned();
if !module.contains(COMPOSE_PLUGIN_ID) {
let plugins = module.find("plugins {")?;
let after = module[plugins..].find('\n')? + plugins + 1;
module_text.insert_str(after, &format!(" id(\"{COMPOSE_PLUGIN_ID}\")\n"));
}
let missing: Vec<&str> = ANDROID_COMPOSE_DEPENDENCIES
.iter()
.copied()
.filter(|coordinate| !module_text.contains(coordinate))
.collect();
if !missing.is_empty() {
let block = module_text.rfind("dependencies {")?;
let after = module_text[block..].find('\n')? + block + 1;
let lines: String = missing
.iter()
.map(|coordinate| format!(" implementation(\"{coordinate}\")\n"))
.collect();
module_text.insert_str(after, &lines);
}
let classpath = format!("org.jetbrains.kotlin:compose-compiler-gradle-plugin:{kotlin}");
let root_text = if root.contains(&classpath) {
None
} else {
let anchor = root.find("classpath(\"org.jetbrains.kotlin:kotlin-gradle-plugin")?;
let after = root[anchor..].find('\n')? + anchor + 1;
let mut text = root.to_owned();
text.insert_str(after, &format!(" classpath(\"{classpath}\")\n"));
Some(text)
};
if module_text == module && root_text.is_none() {
return None;
}
Some((module_text, root_text))
}
fn kotlin_gradle_plugin_version(root: &str) -> Option<String> {
let marker = "org.jetbrains.kotlin:kotlin-gradle-plugin:";
let at = root.find(marker)? + marker.len();
let rest = &root[at..];
let end = rest.find(['"', '\''])?;
Some(rest[..end].to_owned())
}
#[derive(Debug, Eq, PartialEq)]
pub enum SchemaAgreement {
Same,
Unknown,
Different { renderer: String, crate_hash: String },
}
pub fn schema_agreement(renderer_hash: Option<&str>, crate_hash: Option<&str>) -> SchemaAgreement {
let (Some(renderer), Some(crate_hash)) = (renderer_hash, crate_hash) else {
return SchemaAgreement::Unknown;
};
let renderer = renderer.trim();
let crate_hash = crate_hash.trim();
if renderer.is_empty() || crate_hash.is_empty() {
return SchemaAgreement::Unknown;
}
if renderer.eq_ignore_ascii_case(crate_hash) {
SchemaAgreement::Same
} else {
SchemaAgreement::Different {
renderer: renderer.to_owned(),
crate_hash: crate_hash.to_owned(),
}
}
}