use crate::templating::{Subs, is_binary, substitute, templated_path};
use anyhow::{Context, Result, bail};
use include_dir::{Dir, include_dir};
use std::fs;
use std::path::{Path, PathBuf};
static TEMPLATES: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/templates");
pub(crate) const STAMP_REL: &str = ".mobiler/version";
const BASE_REL: &str = ".mobiler/base";
const ANCHORS: &[&str] = &[
"mobiler:plugins",
"mobiler:permissions",
"mobiler:manifest-application",
"mobiler:gradle-deps",
"mobiler:info-plist",
"mobiler:target-extra",
];
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Class {
Own,
Merge,
Shell,
}
fn classify(rel: &Path, desired: &[u8]) -> Class {
let p = rel.to_string_lossy().replace('\\', "/");
let name = rel.file_name().and_then(|n| n.to_str()).unwrap_or("");
let own = p.starts_with("shared/src/")
|| name == "Cargo.toml"
|| p == "Android/settings.gradle.kts"
|| p == "Android/app/src/main/res/values/strings.xml"
|| p == "iOS/Sources/Info.plist"
|| p == "iOS/Sources/App.swift"
|| p.starts_with("iOS/Sources/Assets.xcassets/")
|| is_binary(rel);
if own {
return Class::Own;
}
if let Ok(text) = std::str::from_utf8(desired)
&& ANCHORS.iter().any(|a| text.contains(a))
{
return Class::Merge;
}
Class::Shell
}
#[derive(Default)]
struct Report {
deps: Option<(String, String)>,
deps_note: Option<String>,
up_to_date: usize,
added: Vec<String>,
changed: Vec<String>,
updated: Vec<String>,
merge: Vec<String>,
conflict: Vec<String>,
stamp: Option<(Option<String>, String)>,
}
pub fn run(apply: bool) -> Result<()> {
let root = std::env::current_dir().context("reading current directory")?;
let report = upgrade_at(&root, apply)?;
report.print(apply);
Ok(())
}
fn upgrade_at(root: &Path, apply: bool) -> Result<Report> {
if !root.join("Android").is_dir() || !root.join("iOS").is_dir() || !root.join("shared").is_dir() {
bail!("run `mobiler upgrade` from a Mobiler app root (the dir with Android/, iOS/, shared/)");
}
let subs = Subs::from_app_root(root)?;
let mut report = Report::default();
bump_core_dep(root, &mut report)?;
sync_dir(&TEMPLATES, root, &subs, apply, &mut report)?;
write_stamp(root, &mut report)?;
Ok(report)
}
fn template_core_version() -> Option<String> {
let f = TEMPLATES.get_file("shared/Cargo.toml.tmpl")?;
extract_dep_version(f.contents_utf8()?, "mobiler-core")
}
fn extract_dep_version(cargo: &str, dep: &str) -> Option<String> {
let prefix = format!("{dep} = \"");
cargo.lines().find_map(|l| {
let rest = l.trim_start().strip_prefix(&prefix)?;
rest.split('"').next().map(str::to_string)
})
}
fn bump_core_dep(root: &Path, report: &mut Report) -> Result<()> {
let Some(want) = template_core_version() else {
return Ok(());
};
let path = root.join("shared/Cargo.toml");
if !path.exists() {
report.deps_note = Some("no shared/Cargo.toml found — couldn't bump mobiler-core.".into());
return Ok(());
}
let content = fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
let Some(have) = extract_dep_version(&content, "mobiler-core") else {
report.deps_note = Some(
"couldn't find a `mobiler-core = \"…\"` dependency in shared/Cargo.toml — update it by hand."
.into(),
);
return Ok(());
};
if have == want {
return Ok(());
}
let updated = content.replacen(
&format!("mobiler-core = \"{have}\""),
&format!("mobiler-core = \"{want}\""),
1,
);
fs::write(&path, updated).with_context(|| format!("writing {}", path.display()))?;
report.deps = Some((have, want));
Ok(())
}
fn sync_dir(dir: &Dir<'_>, root: &Path, subs: &Subs, apply: bool, report: &mut Report) -> Result<()> {
for entry in dir.entries() {
match entry {
include_dir::DirEntry::Dir(sub) => sync_dir(sub, root, subs, apply, report)?,
include_dir::DirEntry::File(file) => sync_file(file, root, subs, apply, report)?,
}
}
Ok(())
}
fn sync_file(
file: &include_dir::File<'_>,
root: &Path,
subs: &Subs,
apply: bool,
report: &mut Report,
) -> Result<()> {
let rel = templated_path(file.path(), subs);
let desired: Vec<u8> = if is_binary(file.path()) {
file.contents().to_vec()
} else {
let raw = std::str::from_utf8(file.contents())
.with_context(|| format!("template {} is not UTF-8", file.path().display()))?;
substitute(raw, subs).into_bytes()
};
let class = classify(&rel, &desired);
if class == Class::Own {
return Ok(());
}
let dst = root.join(&rel);
let rel_disp = rel.to_string_lossy().to_string();
if !dst.exists() {
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
fs::write(&dst, &desired).with_context(|| format!("writing {}", dst.display()))?;
report.added.push(rel_disp);
write_baseline(root, &rel, &desired)?;
return Ok(());
}
let current = fs::read(&dst).with_context(|| format!("reading {}", dst.display()))?;
let Ok(pristine) = std::str::from_utf8(&desired).map(str::to_string) else {
if current == desired {
report.up_to_date += 1;
} else {
shell_write(&dst, ¤t, &desired, apply, &rel_disp, report)?;
}
return Ok(());
};
let current_s = String::from_utf8_lossy(¤t).into_owned();
let incorporated = match read_baseline(root, &rel) {
Some(base) => three_way(&base, ¤t_s, &pristine, &dst, &rel_disp, apply, report)?,
None => two_way(class, ¤t, &pristine, &dst, &rel_disp, apply, report)?,
};
if incorporated {
write_baseline(root, &rel, pristine.as_bytes())?;
}
Ok(())
}
fn three_way(
base: &str,
current: &str,
pristine: &str,
dst: &Path,
rel_disp: &str,
apply: bool,
report: &mut Report,
) -> Result<bool> {
if current == pristine {
report.up_to_date += 1;
return Ok(true);
}
match diffy::merge(base, current, pristine) {
Ok(merged) if merged == current => {
report.up_to_date += 1; Ok(true)
}
Ok(merged) if apply => {
write_sidecar(dst, "mobiler-bak", current.as_bytes())?;
fs::write(dst, &merged).with_context(|| format!("writing {}", dst.display()))?;
report.updated.push(rel_disp.to_string());
Ok(true)
}
Ok(merged) => {
write_sidecar(dst, "mobiler-new", merged.as_bytes())?;
report.changed.push(rel_disp.to_string());
Ok(false)
}
Err(conflicted) => {
write_sidecar(dst, "mobiler-new", conflicted.as_bytes())?;
report.conflict.push(rel_disp.to_string());
Ok(false)
}
}
}
fn two_way(
class: Class,
current: &[u8],
pristine: &str,
dst: &Path,
rel_disp: &str,
apply: bool,
report: &mut Report,
) -> Result<bool> {
let mut merge_failed = false;
let spliced = if class == Class::Merge {
std::str::from_utf8(current).ok().and_then(|cur| merge_anchors(pristine, cur))
} else {
None
};
let desired: Vec<u8> = if let Some(m) = spliced {
m.into_bytes()
} else if class == Class::Merge {
merge_failed = true; pristine.as_bytes().to_vec()
} else {
pristine.as_bytes().to_vec()
};
if current == desired.as_slice() {
report.up_to_date += 1;
return Ok(true);
}
if class == Class::Merge && merge_failed {
write_sidecar(dst, "mobiler-new", &desired)?;
report.merge.push(rel_disp.to_string());
return Ok(false);
}
shell_write(dst, current, &desired, apply, rel_disp, report)
}
fn shell_write(
dst: &Path,
current: &[u8],
desired: &[u8],
apply: bool,
rel_disp: &str,
report: &mut Report,
) -> Result<bool> {
if apply {
write_sidecar(dst, "mobiler-bak", current)?;
fs::write(dst, desired).with_context(|| format!("writing {}", dst.display()))?;
report.updated.push(rel_disp.to_string());
Ok(true)
} else {
write_sidecar(dst, "mobiler-new", desired)?;
report.changed.push(rel_disp.to_string());
Ok(false)
}
}
fn baseline_path(root: &Path, rel: &Path) -> PathBuf {
root.join(BASE_REL).join(rel)
}
fn read_baseline(root: &Path, rel: &Path) -> Option<String> {
fs::read_to_string(baseline_path(root, rel)).ok()
}
fn write_baseline(root: &Path, rel: &Path, bytes: &[u8]) -> Result<()> {
let path = baseline_path(root, rel);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
fs::write(&path, bytes).with_context(|| format!("writing baseline {}", path.display()))
}
pub(crate) fn seed_baseline(root: &Path, subs: &Subs) -> Result<()> {
seed_dir(&TEMPLATES, root, subs)
}
fn seed_dir(dir: &Dir<'_>, root: &Path, subs: &Subs) -> Result<()> {
for entry in dir.entries() {
match entry {
include_dir::DirEntry::Dir(sub) => seed_dir(sub, root, subs)?,
include_dir::DirEntry::File(file) => {
if is_binary(file.path()) {
continue;
}
let Ok(raw) = std::str::from_utf8(file.contents()) else { continue };
let rel = templated_path(file.path(), subs);
let content = substitute(raw, subs);
if classify(&rel, content.as_bytes()) == Class::Own {
continue;
}
write_baseline(root, &rel, content.as_bytes())?;
}
}
}
Ok(())
}
fn merge_anchors(new_tmpl: &str, current: &str) -> Option<String> {
let is_marker = |line: &str| ANCHORS.iter().any(|a| line.contains(*a));
let stock: std::collections::HashSet<&str> =
new_tmpl.lines().map(str::trim).filter(|l| !l.is_empty()).collect();
let cur_lines: Vec<&str> = current.lines().collect();
let ends_with_nl = new_tmpl.ends_with('\n');
let mut out: Vec<String> = Vec::new();
for line in new_tmpl.lines() {
if is_marker(line) {
let anchor: &str = ANCHORS.iter().copied().find(|a| line.contains(a))?;
let cur_idx = cur_lines.iter().position(|l| l.contains(anchor))?;
let mut injected: Vec<&str> = Vec::new();
let mut i = cur_idx;
while i > 0 {
let above = cur_lines[i - 1];
if above.trim().is_empty() || stock.contains(above.trim()) {
break;
}
injected.push(above);
i -= 1;
}
injected.reverse();
out.extend(injected.into_iter().map(str::to_string));
}
out.push(line.to_string());
}
let mut merged = out.join("\n");
if ends_with_nl {
merged.push('\n');
}
Some(merged)
}
fn write_sidecar(dst: &Path, suffix: &str, bytes: &[u8]) -> Result<()> {
let side = PathBuf::from(format!("{}.{suffix}", dst.display()));
fs::write(&side, bytes).with_context(|| format!("writing {}", side.display()))?;
Ok(())
}
pub(crate) fn write_version_stamp(root: &Path) -> Result<Option<String>> {
let path = root.join(STAMP_REL);
let prev = fs::read_to_string(&path)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
fs::write(&path, format!("{}\n", env!("CARGO_PKG_VERSION")))
.with_context(|| format!("writing {}", path.display()))?;
Ok(prev)
}
fn write_stamp(root: &Path, report: &mut Report) -> Result<()> {
let prev = write_version_stamp(root)?;
report.stamp = Some((prev, env!("CARGO_PKG_VERSION").to_string()));
Ok(())
}
impl Report {
fn print(&self, apply: bool) {
match (&self.deps, &self.deps_note) {
(Some((from, to)), _) => println!(" deps: mobiler-core {from} -> {to} (updated)"),
(None, Some(note)) => println!(" deps: {note}"),
(None, None) => println!(" deps: up to date"),
}
for a in &self.added {
println!(" + added {a}");
}
for u in &self.updated {
println!(" ~ updated {u} (.mobiler-bak saved)");
}
for c in &self.changed {
println!(" ~ changed {c} -> {c}.mobiler-new");
}
for m in &self.merge {
println!(" ! merge {m} (plugin/user state) -> {m}.mobiler-new");
}
for c in &self.conflict {
println!(" ‼ conflict {c} (overlapping edits) -> {c}.mobiler-new");
}
println!(" = {} file(s) up to date", self.up_to_date);
if let Some((prev, cur)) = &self.stamp {
match prev {
Some(p) if p != cur => println!(" stamp: {p} -> {cur}"),
_ => println!(" stamp: v{cur}"),
}
}
println!();
let pending = self.changed.len() + self.merge.len() + self.conflict.len();
if pending == 0 && self.updated.is_empty() {
println!("Up to date. ✓");
return;
}
if !self.conflict.is_empty() {
println!(
"{} file(s) have overlapping edits — resolve the conflict markers in their \
.mobiler-new, then replace the original (never auto-applied).",
self.conflict.len()
);
}
if !self.changed.is_empty() {
if apply {
} else {
println!(
"Review the {} .mobiler-new shell file(s) and merge, or re-run with `--apply` \
to overwrite in place (a .mobiler-bak is saved).",
self.changed.len()
);
}
}
if !self.updated.is_empty() {
println!(
"Overwrote {} shell file(s); your previous versions are saved as *.mobiler-bak.",
self.updated.len()
);
}
if !self.merge.is_empty() {
println!(
"{} file(s) carry plugin/user state — merge their .mobiler-new by hand (never auto-overwritten).",
self.merge.len()
);
}
}
}
#[cfg(test)]
mod test {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
static COUNTER: AtomicUsize = AtomicUsize::new(0);
#[test]
fn classify_buckets() {
assert_eq!(classify(Path::new("shared/src/app.rs"), b"fn main(){}"), Class::Own);
assert_eq!(classify(Path::new("shared/Cargo.toml"), b""), Class::Own);
assert_eq!(classify(Path::new("Android/settings.gradle.kts"), b""), Class::Own);
assert_eq!(classify(Path::new("iOS/Sources/App.swift"), b""), Class::Own);
assert_eq!(
classify(Path::new("Android/app/src/main/res/mipmap-hdpi/ic_launcher.webp"), b"\x00"),
Class::Own
);
assert_eq!(
classify(Path::new("Android/app/src/main/java/dev/x/Core.kt"), b"// mobiler:plugins\n"),
Class::Merge
);
assert_eq!(classify(Path::new("iOS/Sources/Render.swift"), b"func render(){}"), Class::Shell);
assert_eq!(classify(Path::new("rust-toolchain.toml"), b"[toolchain]"), Class::Shell);
}
#[test]
fn merge_anchors_updates_shell_and_preserves_injections() {
let new_tmpl = "deps {\n impl(\"material-icons-extended\")\n impl(\"okhttp\")\n // mobiler:gradle-deps — insert above\n}\n";
let fresh = "deps {\n impl(\"material-icons-core\")\n impl(\"okhttp\")\n // mobiler:gradle-deps — insert above\n}\n";
assert_eq!(merge_anchors(new_tmpl, fresh).as_deref(), Some(new_tmpl));
let with_plugin =
"deps {\n impl(\"material-icons-core\")\n impl(\"okhttp\")\n impl(\"play-services-scanner\")\n // mobiler:gradle-deps — insert above\n}\n";
let merged = merge_anchors(new_tmpl, with_plugin).unwrap();
assert!(merged.contains("material-icons-extended"), "shell evolution applied");
assert!(merged.contains("play-services-scanner"), "plugin injection preserved");
assert!(!merged.contains("material-icons-core"), "stale base dep dropped");
assert_eq!(merge_anchors(new_tmpl, "deps {\n}\n"), None);
}
#[test]
fn three_way_applies_framework_change_preserves_edit_and_flags_conflict() {
let root = skeleton();
let dst = root.join("f.txt");
let side = |s: &str| PathBuf::from(format!("{}.{s}", dst.display()));
let base = "1\n2\n3\n4\n5\n6\n7\n";
let user = "1\n2\n3\n4\n5\nSIX\n7\n";
let new = "1\nTWO\n3\n4\n5\n6\n7\n";
fs::write(&dst, user).unwrap();
let mut r = Report::default();
let inc = three_way(base, user, new, &dst, "f.txt", true, &mut r).unwrap();
assert!(inc, "clean merge incorporates the new template");
assert_eq!(fs::read_to_string(&dst).unwrap(), "1\nTWO\n3\n4\n5\nSIX\n7\n", "both edits merged");
assert!(side("mobiler-bak").exists(), "backup saved");
assert_eq!(r.updated.len(), 1);
let user_c = "1\n2\n3\nUSER\n5\n6\n7\n";
let new_c = "1\n2\n3\nTMPL\n5\n6\n7\n";
fs::write(&dst, user_c).unwrap();
let mut r2 = Report::default();
let inc2 = three_way(base, user_c, new_c, &dst, "f.txt", true, &mut r2).unwrap();
assert!(!inc2, "conflict does not advance the baseline");
assert_eq!(fs::read_to_string(&dst).unwrap(), user_c, "original left untouched on conflict");
assert_eq!(r2.conflict.len(), 1);
assert!(
fs::read_to_string(side("mobiler-new")).unwrap().contains("<<<<<<<"),
"conflict markers offered for resolution"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn new_seeds_baseline_so_upgrade_is_idempotent() {
let root = skeleton();
let subs = Subs::from_package("dev.mobiler.demo".into(), "Demo".into(), "30.0.14904198".into());
sync_dir(&TEMPLATES, &root, &subs, true, &mut Report::default()).unwrap();
seed_baseline(&root, &subs).unwrap();
assert!(root.join(".mobiler/base/iOS/Sources/Render.swift").exists(), "baseline seeded");
let report = upgrade_at(&root, false).unwrap();
assert!(report.changed.is_empty() && report.merge.is_empty() && report.conflict.is_empty(),
"a current, baselined app has nothing pending");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn extract_dep_version_reads_string_form() {
let cargo = "[dependencies]\ncrux_core.workspace = true\nmobiler-core = \"0.11\"\n";
assert_eq!(extract_dep_version(cargo, "mobiler-core").as_deref(), Some("0.11"));
assert_eq!(extract_dep_version(cargo, "nope"), None);
}
fn skeleton() -> PathBuf {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let root = std::env::temp_dir().join(format!("mob_upgrade_test_{}_{n}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let pkg = root.join("Android/app/src/main/java/dev/mobiler/demo");
fs::create_dir_all(&pkg).unwrap();
fs::create_dir_all(root.join("iOS/Sources")).unwrap();
fs::create_dir_all(root.join("shared/src")).unwrap();
fs::write(pkg.join("MainActivity.kt"), "package dev.mobiler.demo\nclass MainActivity\n").unwrap();
fs::write(root.join("Android/settings.gradle.kts"), "rootProject.name = \"Demo\"\n").unwrap();
fs::write(
root.join("shared/Cargo.toml"),
"[dependencies]\nserde = \"1\"\nmobiler-core = \"0.1.0\"\n",
)
.unwrap();
fs::write(root.join("shared/src/app.rs"), "// MY CUSTOM APP — do not touch\n").unwrap();
root
}
fn read(root: &Path, rel: &str) -> String {
fs::read_to_string(root.join(rel)).unwrap()
}
#[test]
fn bumps_dep_stamps_and_leaves_app_code_untouched() {
let root = skeleton();
let want = template_core_version().expect("templates pin mobiler-core");
let report = upgrade_at(&root, false).unwrap();
let cargo = read(&root, "shared/Cargo.toml");
assert!(cargo.contains(&format!("mobiler-core = \"{want}\"")), "core bumped");
assert!(cargo.contains("serde = \"1\""), "other deps preserved");
assert_eq!(report.deps, Some(("0.1.0".into(), want)));
assert_eq!(read(&root, "shared/src/app.rs"), "// MY CUSTOM APP — do not touch\n");
assert!(!root.join("shared/src/app.rs.mobiler-new").exists());
assert_eq!(read(&root, ".mobiler/version").trim(), env!("CARGO_PKG_VERSION"));
assert!(root.join("iOS/Sources/Render.swift").exists(), "missing shell file added");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn changed_shell_writes_new_then_apply_overwrites_with_backup() {
let root = skeleton();
fs::write(root.join("rust-toolchain.toml"), "OLD\n").unwrap();
upgrade_at(&root, false).unwrap();
assert_eq!(read(&root, "rust-toolchain.toml"), "OLD\n", "original untouched by default");
let new = read(&root, "rust-toolchain.toml.mobiler-new");
assert!(new.contains("toolchain"), "the new template was written as .mobiler-new");
upgrade_at(&root, true).unwrap();
assert_eq!(read(&root, "rust-toolchain.toml"), new, "apply installed the new template");
assert_eq!(read(&root, "rust-toolchain.toml.mobiler-bak"), "OLD\n", "old content backed up");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn merge_file_without_usable_anchor_stays_hands_off() {
let root = skeleton();
let core = root.join("Android/app/src/main/java/dev/mobiler/demo/Core.kt");
fs::write(&core, "package dev.mobiler.demo\n// my installed plugins\n").unwrap();
upgrade_at(&root, true).unwrap(); assert_eq!(
fs::read_to_string(&core).unwrap(),
"package dev.mobiler.demo\n// my installed plugins\n",
"unmergeable MERGE file is never overwritten"
);
assert!(
root.join("Android/app/src/main/java/dev/mobiler/demo/Core.kt.mobiler-new").exists(),
"offered as .mobiler-new"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn reports_previous_stamp_on_reupgrade() {
let root = skeleton();
fs::create_dir_all(root.join(".mobiler")).unwrap();
fs::write(root.join(STAMP_REL), "0.9.0\n").unwrap();
let report = upgrade_at(&root, false).unwrap();
assert_eq!(report.stamp, Some((Some("0.9.0".into()), env!("CARGO_PKG_VERSION").to_string())));
let _ = fs::remove_dir_all(&root);
}
}