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 PENDING_REL: &str = ".mobiler/pending";
const ANCHORS: &[&str] = &[
"mobiler:plugins",
"mobiler:plugins-stream",
"mobiler:app-launch",
"mobiler:permissions",
"mobiler:manifest-application",
"mobiler:gradle-deps",
"mobiler:gradle-plugins",
"mobiler:gradle-plugins-classpath",
"mobiler:info-plist",
"mobiler:target-extra",
"mobiler:spm-packages",
"mobiler:spm-dependencies",
"mobiler:codegen-types",
];
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Class {
Own,
Merge,
Shell,
Seed,
}
const SEED_PATHS: &[&str] = &[
"Android/app/src/main/res/values/mobiler_splash.xml",
"Android/app/src/main/res/values-night/mobiler_splash.xml",
"Android/app/src/main/res/drawable/mobiler_launch.xml",
"iOS/Sources/Assets.xcassets/MobilerSplashBackground.colorset/Contents.json",
];
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("");
if SEED_PATHS.contains(&p.as_str()) {
return Class::Seed;
}
let own = (p.starts_with("shared/src/") && p != "shared/src/bin/codegen.rs")
|| name == "Cargo.toml"
|| p == "Android/settings.gradle.kts"
|| p == "Android/app/src/main/res/values/strings.xml"
|| p == "iOS/Sources/Info.plist"
|| 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>,
resolved: Vec<String>,
markers: Vec<String>,
pending: Vec<(String, String)>,
new_review: Vec<String>,
deps_would: Option<(String, String)>,
dropped: Vec<String>,
managed: std::collections::HashSet<String>,
plugins: Vec<String>,
app_root: Option<PathBuf>,
stamp: Option<(Option<String>, String)>,
}
pub fn run(apply: bool, resolved: &[String]) -> Result<()> {
let root = std::env::current_dir().context("reading current directory")?;
let resolved: Vec<&str> = resolved.iter().map(String::as_str).collect();
let report = upgrade_with(&root, apply, &resolved)?;
report.print(apply);
Ok(())
}
#[cfg(test)]
fn upgrade_at(root: &Path, apply: bool) -> Result<Report> {
upgrade_with(root, apply, &[])
}
fn upgrade_with(root: &Path, apply: bool, resolved: &[&str]) -> 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 { app_root: Some(root.to_path_buf()), ..Report::default() };
let settle = resolved.iter().map(|file| check_resolved(root, file)).collect::<Result<Vec<_>>>()?;
let mut seen = std::collections::HashSet::new();
for (rel, rec) in settle {
if !seen.insert(rel.clone()) {
continue; }
write_baseline(root, &rel, rec.template.as_bytes())?;
clear_pending(root, &rel);
let rel_disp = rel.to_string_lossy().to_string();
let _ = fs::remove_file(root.join(review_rel(&rel_disp)));
report.resolved.push(rel_disp);
}
bump_core_dep(root, apply, &mut report)?;
sync_dir(&TEMPLATES, root, &subs, apply, &mut report)?;
report.plugins = crate::plugin::drifted(root, &subs);
for (rel, _) in list_pending(root) {
if !report.managed.contains(&rel) {
clear_pending(root, Path::new(&rel));
report.dropped.push(rel);
}
}
report.pending = list_pending(root);
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, apply: bool, 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(());
}
if !apply {
report.deps_would = Some((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(());
}
if class == Class::Seed {
let dst = root.join(&rel);
if dst.symlink_metadata().is_err() {
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
let mut f = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&dst)
.with_context(|| format!("creating {}", dst.display()))?;
std::io::Write::write_all(&mut f, &desired).with_context(|| format!("writing {}", dst.display()))?;
report.added.push(rel.to_string_lossy().to_string());
}
return Ok(());
}
let dst = root.join(&rel);
let rel_disp = rel.to_string_lossy().to_string();
report.managed.insert(rel_disp.clone());
if check_pending(root, &rel, &dst, &rel_disp, report)? {
return Ok(());
}
if !dst.exists() {
if !apply && let Ok(text) = std::str::from_utf8(&desired) {
write_review(report.app_root.as_deref(), &dst, &desired)?;
let offer = Offer { template: text, current: b"", kind: "new", offered: &desired };
write_pending(root, &rel, &offer)?;
report.new_review.push(rel_disp);
return Ok(());
}
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)?;
clear_pending(root, &rel);
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 (conflicts, merges) = (report.conflict.len(), report.merge.len());
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())?;
clear_pending(root, &rel);
} else {
let kind = if report.conflict.len() > conflicts {
"conflict"
} else if report.merge.len() > merges {
"merge"
} else {
"review"
};
let offered = fs::read(root.join(review_rel(&rel_disp))).unwrap_or_default();
let offer = Offer { template: &pristine, current: ¤t, kind, offered: &offered };
write_pending(root, &rel, &offer)?;
}
Ok(())
}
fn check_pending(root: &Path, rel: &Path, dst: &Path, rel_disp: &str, report: &mut Report) -> Result<bool> {
if let Some(rec) = read_pending(root, rel)
&& dst.exists()
{
let now_bytes = fs::read(dst).with_context(|| format!("reading {}", dst.display()))?;
if fingerprint(&now_bytes) != rec.file_hash {
let now = String::from_utf8_lossy(&now_bytes);
if has_conflict_markers(&now) {
report.markers.push(rel_disp.to_string());
return Ok(true);
}
let resolved = fingerprint(&now_bytes) == rec.offered_hash;
if resolved {
write_baseline(root, rel, rec.template.as_bytes())?;
clear_pending(root, rel);
report.resolved.push(rel_disp.to_string());
}
}
}
Ok(false)
}
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_backup(report.app_root.as_deref(), dst, 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_review(report.app_root.as_deref(), dst, merged.as_bytes())?;
report.changed.push(rel_disp.to_string());
Ok(false)
}
Err(conflicted) => {
write_review(report.app_root.as_deref(), dst, 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().map(|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_review(report.app_root.as_deref(), dst, &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_backup(report.app_root.as_deref(), dst, current)?;
fs::write(dst, desired).with_context(|| format!("writing {}", dst.display()))?;
report.updated.push(rel_disp.to_string());
Ok(true)
} else {
write_review(report.app_root.as_deref(), dst, 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()))
}
fn pending_path(root: &Path, rel: &Path) -> PathBuf {
let mut p = root.join(PENDING_REL).join(rel).into_os_string();
p.push(".json");
PathBuf::from(p)
}
fn fingerprint(bytes: &[u8]) -> String {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for (i, b) in bytes.iter().enumerate() {
if *b == b'\r' && bytes.get(i + 1) == Some(&b'\n') {
continue;
}
h ^= u64::from(*b);
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("{h:016x}")
}
struct PendingRecord {
template: String,
file_hash: String,
kind: String,
offered_hash: String,
}
fn read_pending(root: &Path, rel: &Path) -> Option<PendingRecord> {
let text = fs::read_to_string(pending_path(root, rel)).ok()?;
let v: serde_json::Value = serde_json::from_str(&text).ok()?;
Some(PendingRecord {
template: v["template"].as_str()?.to_string(),
file_hash: v["file_hash"].as_str()?.to_string(),
kind: v["kind"].as_str()?.to_string(),
offered_hash: v["offered_hash"].as_str().unwrap_or_default().to_string(),
})
}
struct Offer<'a> {
template: &'a str,
current: &'a [u8],
kind: &'a str,
offered: &'a [u8],
}
fn write_pending(root: &Path, rel: &Path, offer: &Offer<'_>) -> Result<()> {
let path = pending_path(root, rel);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
let json = serde_json::json!({
"template": offer.template,
"file_hash": fingerprint(offer.current),
"kind": offer.kind,
"offered_hash": fingerprint(offer.offered),
});
fs::write(&path, serde_json::to_string_pretty(&json)?).with_context(|| format!("writing {}", path.display()))
}
fn clear_pending(root: &Path, rel: &Path) {
let path = pending_path(root, rel);
let _ = fs::remove_file(&path);
let top = root.join(PENDING_REL);
let mut dir = path.parent();
while let Some(d) = dir.filter(|d| d.starts_with(&top) && *d != top) {
if fs::remove_dir(d).is_err() {
break;
}
dir = d.parent();
}
}
fn has_conflict_markers(text: &str) -> bool {
text.lines().any(|l| {
l.starts_with("<<<<<<< ") || l.starts_with("||||||| ") || l == "=======" || l.starts_with(">>>>>>> ")
})
}
fn check_resolved(root: &Path, arg: &str) -> Result<(PathBuf, PendingRecord)> {
let given = Path::new(arg);
let canon_root = fs::canonicalize(root).ok();
let inside = given
.strip_prefix(root)
.ok()
.or_else(|| canon_root.as_deref().and_then(|c| given.strip_prefix(c).ok()))
.unwrap_or(given);
let mut rel = inside.to_string_lossy().replace('\\', "/");
while let Some(r) = rel.strip_prefix("./") {
rel = r.to_string();
}
if let Some(r) = rel.strip_prefix(".mobiler/new/") {
rel = r.to_string();
}
if let Some(r) = rel.strip_suffix(".mobiler-new") {
rel = r.to_string();
}
let given_rel = PathBuf::from(&rel);
let components: Vec<_> = given_rel.components().filter(|c| !matches!(c, std::path::Component::CurDir)).collect();
if components.is_empty() || !components.iter().all(|c| matches!(c, std::path::Component::Normal(_))) {
bail!("{arg}: give a path inside the app (relative to its root)");
}
let rel_path: PathBuf = components.iter().collect();
let rel = rel_path.to_string_lossy().to_string();
let Some(rec) = read_pending(root, &rel_path) else {
bail!("no pending review for {rel} — `mobiler upgrade` lists the files waiting for one");
};
let Ok(bytes) = fs::read(root.join(&rel_path)) else {
bail!("{rel} doesn't exist — put your version in place first (or move its review copy in)");
};
if has_conflict_markers(&String::from_utf8_lossy(&bytes)) {
bail!("{rel} still has conflict markers — finish resolving them, then run --resolved again");
}
Ok((rel_path, rec))
}
fn list_pending(root: &Path) -> Vec<(String, String)> {
fn walk(dir: &Path, base: &Path, root: &Path, out: &mut Vec<(String, String)>) {
let Ok(entries) = fs::read_dir(dir) else { return };
for e in entries.flatten() {
let p = e.path();
if e.file_type().is_ok_and(|t| t.is_dir()) {
walk(&p, base, root, out);
} else if let Some(rel) = p.strip_prefix(base).ok().and_then(|r| r.to_str()).and_then(|r| r.strip_suffix(".json"))
&& let Some(rec) = read_pending(root, Path::new(rel))
{
out.push((rel.to_string(), rec.kind));
}
}
}
let base = root.join(PENDING_REL);
let mut out = Vec::new();
walk(&base, &base, root, &mut out);
out.sort();
out
}
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 matches!(classify(&rel, content.as_bytes()), Class::Own | Class::Seed) {
continue;
}
write_baseline(root, &rel, content.as_bytes())?;
}
}
}
Ok(())
}
fn merge_anchors(new_tmpl: &str, current: &str) -> String {
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 let Some(anchor) = ANCHORS.iter().copied().find(|a| line.contains(a)) {
if let Some(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');
}
merged
}
fn write_backup(app_root: Option<&Path>, dst: &Path, bytes: &[u8]) -> Result<()> {
let Some((root, rel)) = app_root.and_then(|root| dst.strip_prefix(root).ok().map(|rel| (root, rel))) else {
return write_sidecar(dst, "mobiler-bak", bytes);
};
let backup = root.join(".mobiler/backup").join(rel);
if let Some(parent) = backup.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
fs::write(&backup, bytes).with_context(|| format!("writing {}", backup.display()))
}
fn is_android_resource(rel: &str) -> bool {
let parts: Vec<&str> = rel.split('/').collect();
parts.iter().enumerate().any(|(i, p)| *p == "res" && i >= 2 && parts[i - 2] == "src" && i + 1 < parts.len())
}
fn review_rel(rel: &str) -> String {
let rel = rel.replace('\\', "/");
if is_android_resource(&rel) { format!(".mobiler/new/{rel}.mobiler-new") } else { format!("{rel}.mobiler-new") }
}
fn write_review(app_root: Option<&Path>, dst: &Path, bytes: &[u8]) -> Result<()> {
let Some((root, rel)) = app_root.and_then(|root| dst.strip_prefix(root).ok().map(|rel| (root, rel))) else {
return write_sidecar(dst, "mobiler-new", bytes);
};
let side = root.join(review_rel(&rel.to_string_lossy()));
if let Some(parent) = side.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
fs::write(&side, bytes).with_context(|| format!("writing {}", side.display()))
}
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 pending_lines(&self) -> Vec<String> {
if self.pending.is_empty() {
return Vec::new();
}
let mut lines = vec![format!("⚠ {} file(s) still need your review from an upgrade:", self.pending.len())];
for (rel, kind) in &self.pending {
let copy = review_rel(rel);
let what = if self.markers.contains(rel) {
format!("conflict markers left in the file — finish resolving, then `mobiler upgrade --resolved {rel}`")
} else {
match kind.as_str() {
"conflict" => format!(
"conflict — resolve {copy}, replace the file, then run `mobiler upgrade --resolved {rel}`"
),
"merge" => format!(
"plugin/user state — merge {copy} by hand, then `mobiler upgrade --resolved {rel}`"
),
"new" => format!("new file — move {copy} into place, or run with --apply"),
_ => format!(
"review {copy} and replace the file, run with --apply, or keep yours with `--resolved {rel}`"
),
}
};
lines.push(format!(" {rel} {what}"));
}
lines.push(" Review copies are confirmed on the next run; a conflict when you run --resolved.".to_string());
lines
}
fn print_files(&self) {
if let Some((from, to)) = &self.deps_would {
println!(" deps: would bump mobiler-core {from} -> {to} (run with --apply)");
} else {
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 n in &self.new_review {
println!(" + new {n} -> {}", review_rel(n));
}
for u in &self.updated {
println!(" ~ updated {u} (previous version in .mobiler/backup/)");
}
for c in &self.changed {
println!(" ~ changed {c} -> {}", review_rel(c));
}
for m in &self.merge {
println!(" ! merge {m} (plugin/user state) -> {}", review_rel(m));
}
for c in &self.conflict {
println!(" ‼ conflict {c} (overlapping edits) -> {}", review_rel(c));
}
for r in &self.resolved {
println!(" ✓ resolved {r} (baseline advanced; it stays resolved)");
}
for m in &self.markers {
println!(" ‼ markers {m} (conflict markers left in the file)");
}
for d in &self.dropped {
println!(" - dropped {d} (no longer part of the template; its pending review was removed)");
}
for p in &self.plugins {
println!(" ! plugin {p} has shell updates in this release");
}
}
fn print(&self, apply: bool) {
self.print_files();
println!(" = {} file(s) up to date", self.up_to_date);
if let Some((prev, cur)) = &self.stamp {
let waiting = if self.pending.is_empty() {
String::new()
} else {
format!(" ({} file(s) pending)", self.pending.len())
};
match prev {
Some(p) if p != cur => println!(" stamp: {p} -> {cur}{waiting}"),
_ => println!(" stamp: v{cur}{waiting}"),
}
}
println!();
for line in self.pending_lines() {
println!("{line}");
}
if !self.plugins.is_empty() {
println!(
"{} installed plugin(s) ship updated shell code in this release. Their Rust API \
comes with the core bump, but the native half is only updated by re-adding:",
self.plugins.len()
);
for p in &self.plugins {
println!(" mobiler plugin add {p}");
}
println!(
" This overwrites your copy of those plugins' shell sources — back up any local \
edits first (registrations and other files are left alone)."
);
}
let offered = self.changed.len() + self.new_review.len() + self.merge.len() + self.conflict.len();
if offered == 0 && self.updated.is_empty() {
if self.plugins.is_empty() && self.pending.is_empty() && self.deps_would.is_none() {
println!("Up to date. ✓");
}
return;
}
if !self.conflict.is_empty() {
println!(
"{} file(s) have overlapping edits — resolve the conflict markers in their review \
copy (path above), replace the original (never auto-applied), then run \
`mobiler upgrade --resolved <file>`.",
self.conflict.len()
);
}
let reviews = self.changed.len() + self.new_review.len();
if reviews > 0 {
if apply {
} else {
println!(
"Review the {reviews} .mobiler-new shell file(s) and merge, or re-run with `--apply` \
to overwrite in place (previous versions go to .mobiler/backup/)."
);
}
}
if !self.updated.is_empty() {
println!(
"Overwrote {} shell file(s); your previous versions are saved under .mobiler/backup/.",
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("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("Android/build.gradle.kts"), b"plugins {\n // mobiler:gradle-plugins-classpath\n}\n"),
Class::Merge
);
assert_eq!(
classify(Path::new("iOS/Sources/App.swift"), b"@main struct {{NAME}}App {}\n// mobiler:app-launch\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 codegen_rs_is_carved_out_of_own_but_rest_of_shared_src_stays_own() {
assert_eq!(
classify(
Path::new("shared/src/bin/codegen.rs"),
b"TypeRegistry::new()\n .register_app::<App>()?\n .register_type::<mobiler_core::HttpOutcome>()?\n // mobiler:codegen-types - insert above\n .build()?;\n"
),
Class::Merge,
"codegen.rs is carved out of Own and classified via its anchor, like Core.kt/App.swift"
);
assert_eq!(classify(Path::new("shared/src/app.rs"), b"fn main(){}"), Class::Own);
assert_eq!(classify(Path::new("shared/src/lib.rs"), b"pub mod app;"), Class::Own);
assert_eq!(
classify(Path::new("shared/src/bin/other_tool.rs"), b"fn main(){}"),
Class::Own,
"only codegen.rs is carved out — sibling bin/ tools the user might add stay Own"
);
}
#[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), 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);
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"), new_tmpl);
}
#[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()
}
fn toolchain() -> String {
TEMPLATES.get_file("rust-toolchain.toml").unwrap().contents_utf8().unwrap().to_string()
}
fn conflicted_app() -> PathBuf {
let root = skeleton();
let t = toolchain();
fs::create_dir_all(root.join(".mobiler/base")).unwrap();
fs::write(root.join(".mobiler/base/rust-toolchain.toml"), format!("# old\n{t}")).unwrap();
fs::write(root.join("rust-toolchain.toml"), format!("# mine\n{t}")).unwrap();
root
}
#[test]
fn resolved_conflict_stays_resolved() {
let root = conflicted_app();
let r1 = upgrade_at(&root, true).unwrap();
assert!(r1.conflict.iter().any(|c| c == "rust-toolchain.toml"), "{:?}", r1.conflict);
assert!(root.join(".mobiler/pending/rust-toolchain.toml.json").exists(), "the conflict is recorded");
let resolved = format!("# mine, resolved\n{}", toolchain());
fs::write(root.join("rust-toolchain.toml"), &resolved).unwrap();
fs::remove_file(root.join("rust-toolchain.toml.mobiler-new")).unwrap();
let pending = upgrade_at(&root, true).unwrap();
assert!(pending.resolved.is_empty() && pending.conflict.iter().any(|c| c == "rust-toolchain.toml"));
fs::remove_file(root.join("rust-toolchain.toml.mobiler-new")).unwrap();
let r2 = upgrade_with(&root, true, &["rust-toolchain.toml"]).unwrap();
assert_eq!(r2.resolved, ["rust-toolchain.toml"]);
assert!(r2.conflict.is_empty(), "a resolved conflict never comes back: {:?}", r2.conflict);
assert_eq!(read(&root, ".mobiler/base/rust-toolchain.toml"), toolchain(), "baseline advanced");
assert!(!root.join(".mobiler/pending/rust-toolchain.toml.json").exists(), "record cleared");
assert_eq!(read(&root, "rust-toolchain.toml"), resolved, "the user's resolution is kept");
let r3 = upgrade_at(&root, true).unwrap();
assert!(r3.conflict.is_empty() && r3.resolved.is_empty(), "third run is quiet");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn markers_left_in_file_stay_pending() {
let root = conflicted_app();
upgrade_at(&root, true).unwrap();
let review = read(&root, "rust-toolchain.toml.mobiler-new");
fs::write(root.join("rust-toolchain.toml"), &review).unwrap();
let r = upgrade_at(&root, true).unwrap();
assert_eq!(r.markers, ["rust-toolchain.toml"]);
assert!(r.resolved.is_empty());
assert_eq!(read(&root, "rust-toolchain.toml"), review, "a file with markers is never touched");
assert!(root.join(".mobiler/pending/rust-toolchain.toml.json").exists(), "still pending");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn incorporated_file_clears_its_record() {
let root = conflicted_app();
upgrade_at(&root, true).unwrap();
fs::write(root.join("rust-toolchain.toml"), toolchain()).unwrap();
let r = upgrade_at(&root, true).unwrap();
assert!(r.conflict.is_empty());
assert!(!root.join(".mobiler/pending/rust-toolchain.toml.json").exists());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn unresolved_conflict_is_reported_on_every_run() {
let root = conflicted_app();
upgrade_at(&root, true).unwrap();
fs::remove_file(root.join("rust-toolchain.toml.mobiler-new")).unwrap();
let r = upgrade_at(&root, false).unwrap();
assert!(r.pending.contains(&("rust-toolchain.toml".to_string(), "conflict".to_string())), "{:?}", r.pending);
let lines = r.pending_lines();
assert!(lines[0].starts_with('⚠'), "{lines:?}");
assert!(lines.iter().any(|l| l.contains("rust-toolchain.toml") && l.contains("conflict")), "{lines:?}");
assert!(root.join("rust-toolchain.toml.mobiler-new").exists(), "the review copy is offered again");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn review_run_never_touches_build_inputs() {
let root = skeleton();
let cargo_before = read(&root, "shared/Cargo.toml");
let r = upgrade_at(&root, false).unwrap();
assert_eq!(read(&root, "shared/Cargo.toml"), cargo_before, "no core bump without --apply");
assert!(r.deps_would.is_some());
assert!(!root.join("iOS/Sources/Render.swift").exists(), "a new file is not written in place");
assert!(root.join("iOS/Sources/Render.swift.mobiler-new").exists(), "it is offered for review");
assert!(r.pending.iter().any(|(f, k)| f == "iOS/Sources/Render.swift" && k == "new"), "{:?}", r.pending);
let r2 = upgrade_at(&root, true).unwrap();
assert!(root.join("iOS/Sources/Render.swift").exists());
let want = template_core_version().unwrap();
assert!(read(&root, "shared/Cargo.toml").contains(&format!("mobiler-core = \"{want}\"")));
assert!(r2.pending.is_empty(), "--apply leaves nothing pending: {:?}", r2.pending);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn accepted_new_file_is_resolved() {
let root = skeleton();
upgrade_at(&root, false).unwrap();
fs::rename(root.join("iOS/Sources/Render.swift.mobiler-new"), root.join("iOS/Sources/Render.swift")).unwrap();
let r = upgrade_at(&root, false).unwrap();
assert!(r.resolved.iter().any(|f| f == "iOS/Sources/Render.swift"), "{:?}", r.resolved);
assert!(!r.pending.iter().any(|(f, _)| f == "iOS/Sources/Render.swift"));
assert!(root.join(".mobiler/base/iOS/Sources/Render.swift").exists(), "baselined");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn review_offer_survives_an_unrelated_edit() {
let root = skeleton();
let t = toolchain();
fs::create_dir_all(root.join(".mobiler/base")).unwrap();
fs::write(root.join(".mobiler/base/rust-toolchain.toml"), format!("{t}# old tail\n")).unwrap();
fs::write(root.join("rust-toolchain.toml"), format!("{t}# old tail\n")).unwrap();
upgrade_at(&root, false).unwrap();
assert!(read_pending(&root, Path::new("rust-toolchain.toml")).is_some_and(|r| r.kind == "review"));
let edited = format!("{t}# old tail\n").replacen("[toolchain]", "[toolchain] # mine", 1);
fs::write(root.join("rust-toolchain.toml"), &edited).unwrap();
let r = upgrade_at(&root, true).unwrap();
assert!(r.resolved.is_empty(), "an unrelated edit is not a resolution: {:?}", r.resolved);
let after = read(&root, "rust-toolchain.toml");
assert!(after.contains("[toolchain] # mine"), "the user's edit is kept");
assert!(!after.contains("# old tail"), "the framework change landed: {after}");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn conflict_with_an_edit_elsewhere_stays_pending() {
let root = conflicted_app();
upgrade_at(&root, true).unwrap();
let edited = format!("# mine\n{}# added elsewhere\n", toolchain());
fs::write(root.join("rust-toolchain.toml"), &edited).unwrap();
let r = upgrade_at(&root, true).unwrap();
assert!(r.resolved.is_empty(), "the conflict itself is untouched: {:?}", r.resolved);
assert!(r.conflict.iter().any(|c| c == "rust-toolchain.toml"), "offered again: {:?}", r.conflict);
assert_eq!(read(&root, ".mobiler/base/rust-toolchain.toml"), format!("# old\n{}", toolchain()), "baseline kept");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn clean_offer_with_an_adjacent_edit_becomes_a_conflict() {
let root = skeleton();
let t = toolchain();
fs::create_dir_all(root.join(".mobiler/base")).unwrap();
fs::write(root.join(".mobiler/base/rust-toolchain.toml"), format!("{t}# old tail\n")).unwrap();
fs::write(root.join("rust-toolchain.toml"), format!("{t}# old tail\n")).unwrap();
upgrade_at(&root, false).unwrap();
fs::write(root.join("rust-toolchain.toml"), format!("{t}# plugin line\n# old tail\n")).unwrap();
let r = upgrade_at(&root, true).unwrap();
assert!(r.resolved.is_empty(), "{:?}", r.resolved);
assert!(r.conflict.iter().any(|c| c == "rust-toolchain.toml"), "{:?}", r.conflict);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn accepted_review_copy_is_resolved() {
let root = skeleton();
let t = toolchain();
fs::create_dir_all(root.join(".mobiler/base")).unwrap();
fs::write(root.join(".mobiler/base/rust-toolchain.toml"), format!("{t}# old tail\n")).unwrap();
fs::write(root.join("rust-toolchain.toml"), format!("{t}# old tail\n")).unwrap();
upgrade_at(&root, false).unwrap();
fs::rename(root.join("rust-toolchain.toml.mobiler-new"), root.join("rust-toolchain.toml")).unwrap();
let r = upgrade_at(&root, false).unwrap();
assert!(!r.pending.iter().any(|(f, _)| f == "rust-toolchain.toml"), "{:?}", r.pending);
assert_eq!(read(&root, "rust-toolchain.toml"), t);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn clearing_a_record_removes_its_empty_folders() {
let root = skeleton();
let offer = Offer { template: "x\n", current: b"", kind: "review", offered: b"" };
write_pending(&root, Path::new("a/b/C.kt"), &offer).unwrap();
write_pending(&root, Path::new("a/D.kt"), &offer).unwrap();
clear_pending(&root, Path::new("a/b/C.kt"));
assert!(!root.join(".mobiler/pending/a/b").exists(), "empty folder removed");
assert!(root.join(".mobiler/pending/a/D.kt.json").exists(), "a folder still holding a record stays");
let _ = fs::remove_dir_all(&root);
}
fn channel_conflict_app(two_hunks: bool) -> PathBuf {
let root = skeleton();
let t = toolchain();
let mut base = t.replace("channel = \"stable\"", "channel = \"beta\"");
if two_hunks {
base = base.replace("profile = \"minimal\"", "profile = \"default\"");
}
let user = base.replace("channel = \"beta\"", "channel = \"nightly\"");
fs::create_dir_all(root.join(".mobiler/base")).unwrap();
fs::write(root.join(".mobiler/base/rust-toolchain.toml"), &base).unwrap();
fs::write(root.join("rust-toolchain.toml"), &user).unwrap();
root
}
#[test]
fn edit_next_to_a_pending_conflict_stays_pending() {
let root = channel_conflict_app(false);
upgrade_at(&root, true).unwrap();
let now = read(&root, "rust-toolchain.toml").replace("[toolchain]", "[toolchain] # touched");
fs::write(root.join("rust-toolchain.toml"), &now).unwrap();
let r = upgrade_at(&root, true).unwrap();
assert!(r.resolved.is_empty(), "the template's line is not in the file: {:?}", r.resolved);
assert!(r.conflict.iter().any(|c| c == "rust-toolchain.toml"), "{:?}", r.conflict);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn edit_next_to_a_clean_hunk_in_a_conflicted_file_stays_pending() {
let root = channel_conflict_app(true);
upgrade_at(&root, true).unwrap();
let now = read(&root, "rust-toolchain.toml").replace("profile = ", "# plugin line\nprofile = ");
fs::write(root.join("rust-toolchain.toml"), &now).unwrap();
let r = upgrade_at(&root, true).unwrap();
assert!(r.resolved.is_empty(), "{:?}", r.resolved);
assert!(read_pending(&root, Path::new("rust-toolchain.toml")).is_some(), "still pending");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn conflict_resolved_by_hand_waits_for_resolved_flag() {
let root = channel_conflict_app(true);
upgrade_at(&root, true).unwrap();
let resolved = read(&root, "rust-toolchain.toml.mobiler-new")
.lines()
.filter(|l| !l.starts_with("<<<<<<<") && !l.starts_with("|||||||") && !l.starts_with(">>>>>>>") && *l != "=======")
.filter(|l| !l.contains("beta") && !l.contains("nightly"))
.map(|l| format!("{l}\n"))
.collect::<String>()
.replace("channel = \"stable\"\n", "channel = \"stable\" # wanted nightly\nchannel = \"stable\"\n");
fs::write(root.join("rust-toolchain.toml"), &resolved).unwrap();
fs::remove_file(root.join("rust-toolchain.toml.mobiler-new")).unwrap();
let r = upgrade_at(&root, true).unwrap();
assert!(r.resolved.is_empty(), "never inferred: {:?}", r.resolved);
assert!(r.pending.iter().any(|(f, k)| f == "rust-toolchain.toml" && k == "conflict"));
let r2 = upgrade_with(&root, true, &["rust-toolchain.toml"]).unwrap();
assert_eq!(r2.resolved, ["rust-toolchain.toml"], "file: {resolved}");
assert_eq!(read(&root, "rust-toolchain.toml"), resolved, "the user's resolution is kept");
assert!(upgrade_at(&root, true).unwrap().pending.is_empty(), "quiet afterwards");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn keeping_your_own_side_resolves_with_the_flag() {
let root = conflicted_app();
upgrade_at(&root, true).unwrap();
let mine = read(&root, "rust-toolchain.toml");
let r = upgrade_with(&root, true, &["rust-toolchain.toml"]).unwrap();
assert_eq!(r.resolved, ["rust-toolchain.toml"]);
assert!(r.conflict.is_empty(), "{:?}", r.conflict);
assert_eq!(read(&root, "rust-toolchain.toml"), mine, "the user's side is kept");
assert!(!root.join("rust-toolchain.toml.mobiler-new").exists(), "the review copy goes too");
let again = upgrade_at(&root, true).unwrap();
assert!(again.conflict.is_empty() && again.pending.is_empty(), "{:?}", again.pending);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn resolved_flag_refuses_markers_and_unknown_files() {
let root = conflicted_app();
upgrade_at(&root, true).unwrap();
let review = read(&root, "rust-toolchain.toml.mobiler-new");
fs::write(root.join("rust-toolchain.toml"), &review).unwrap();
let err = upgrade_with(&root, true, &["rust-toolchain.toml"]).err().expect("markers refused");
assert!(format!("{err:#}").contains("conflict markers"), "{err:#}");
let err = upgrade_with(&root, true, &["iOS/Sources/Render.swift"]).err().expect("not pending");
assert!(format!("{err:#}").contains("no pending review"), "{err:#}");
fs::write(root.join("rust-toolchain.toml"), format!("# fixed\n{}", toolchain())).unwrap();
let r = upgrade_with(&root, true, &["rust-toolchain.toml.mobiler-new"]).unwrap();
assert_eq!(r.resolved, ["rust-toolchain.toml"]);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_stub_where_a_new_file_was_offered_is_not_settled() {
let root = skeleton();
upgrade_at(&root, false).unwrap();
fs::write(root.join("iOS/Sources/Render.swift"), "// my stub\n").unwrap();
let r = upgrade_at(&root, false).unwrap();
assert!(!r.resolved.iter().any(|f| f == "iOS/Sources/Render.swift"), "{:?}", r.resolved);
assert!(r.pending.iter().any(|(f, _)| f == "iOS/Sources/Render.swift"), "still offered: {:?}", r.pending);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn resolved_rejects_paths_outside_the_app() {
let root = conflicted_app();
upgrade_at(&root, true).unwrap();
let outside = root.with_extension("outside");
fs::create_dir_all(&outside).unwrap();
let record = r#"{"template":"PWNED\n","file_hash":"0","kind":"conflict","offered_hash":"0"}"#;
fs::write(outside.join("victim.json"), record).unwrap();
let name = outside.file_name().unwrap().to_string_lossy().to_string();
let up = format!("../../{name}/victim");
let abs = outside.join("victim").to_string_lossy().to_string();
for arg in [up.as_str(), abs.as_str()] {
let err = upgrade_with(&root, true, &[arg]).err().expect("refused");
assert!(format!("{err:#}").contains("inside the app"), "{err:#}");
}
assert!(!outside.join("victim").exists(), "nothing written outside the app");
assert!(outside.join("victim.json").exists(), "nothing deleted outside the app");
let _ = fs::remove_dir_all(&outside);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn one_bad_resolved_argument_applies_none() {
let root = conflicted_app();
upgrade_at(&root, true).unwrap();
assert!(upgrade_with(&root, true, &["rust-toolchain.toml", "typo/Nope.kt"]).is_err());
assert!(read_pending(&root, Path::new("rust-toolchain.toml")).is_some(), "the good one is not half-applied");
assert!(upgrade_with(&root, true, &["rust-toolchain.toml"]).is_ok(), "and a retry works");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn repeated_resolved_arguments_settle_once() {
let root = conflicted_app();
upgrade_at(&root, true).unwrap();
let r = upgrade_with(&root, true, &["rust-toolchain.toml", "./rust-toolchain.toml", "rust-toolchain.toml.mobiler-new"]).unwrap();
assert_eq!(r.resolved, ["rust-toolchain.toml"]);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn resolved_refuses_a_missing_file() {
let root = conflicted_app();
upgrade_at(&root, true).unwrap();
fs::remove_file(root.join("rust-toolchain.toml")).unwrap();
let err = upgrade_with(&root, true, &["rust-toolchain.toml"]).err().expect("refused");
assert!(format!("{err:#}").contains("doesn't exist"), "{err:#}");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn orphan_record_is_dropped() {
let root = skeleton();
upgrade_at(&root, true).unwrap();
let offer = Offer { template: "x\n", current: b"", kind: "conflict", offered: b"" };
write_pending(&root, Path::new("gone/Old.kt"), &offer).unwrap();
let r = upgrade_at(&root, true).unwrap();
assert_eq!(r.dropped, ["gone/Old.kt"]);
assert!(r.pending.is_empty(), "{:?}", r.pending);
assert!(!root.join(".mobiler/pending/gone/Old.kt.json").exists());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn deleted_pending_file_is_not_resolved() {
let root = conflicted_app();
upgrade_at(&root, true).unwrap();
fs::remove_file(root.join("rust-toolchain.toml")).unwrap();
let r = upgrade_at(&root, true).unwrap();
assert!(r.resolved.is_empty(), "{:?}", r.resolved);
assert!(r.added.iter().any(|a| a == "rust-toolchain.toml"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn fingerprint_ignores_line_endings() {
assert_eq!(fingerprint(b"a\r\nb\r\n"), fingerprint(b"a\nb\n"));
assert_ne!(fingerprint(b"a\nb\n"), fingerprint(b"a\nc\n"));
}
#[test]
fn markers_are_whole_lines() {
assert!(has_conflict_markers("a\n<<<<<<< ours\nb\n=======\nc\n>>>>>>> theirs\n"));
assert!(has_conflict_markers("x\n||||||| original\ny\n"), "a diff3 ancestor block is unresolved");
assert!(!has_conflict_markers("// ======= section =======\nlet x = \"<<<<<<<\";\n"));
}
#[test]
fn corrupt_record_is_ignored() {
let root = conflicted_app();
let p = root.join(".mobiler/pending/rust-toolchain.toml.json");
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(&p, "{ not json").unwrap();
let r = upgrade_at(&root, true).unwrap(); assert!(r.conflict.iter().any(|c| c == "rust-toolchain.toml"));
assert!(read_pending(&root, Path::new("rust-toolchain.toml")).is_some(), "rewritten as a valid record");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn seed_paths_classify_as_seed_and_exist_in_the_template() {
for p in SEED_PATHS {
assert_eq!(classify(Path::new(p), b"x"), Class::Seed, "{p}");
}
assert_eq!(classify(Path::new("iOS/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json"), b"{}"), Class::Own);
for p in SEED_PATHS {
assert!(TEMPLATES.get_file(p).is_some(), "seed path {p} is not in the template");
}
}
#[test]
fn missing_seed_files_are_created_and_existing_ones_never_touched() {
let root = skeleton();
upgrade_at(&root, false).unwrap();
for p in SEED_PATHS {
assert!(root.join(p).exists(), "{p} created");
}
let edited = "<resources><color name=\"mobiler_splash_background\">#123456</color></resources>\n";
fs::write(root.join(SEED_PATHS[0]), edited).unwrap();
upgrade_at(&root, true).unwrap();
assert_eq!(read(&root, SEED_PATHS[0]), edited, "seed file left byte-identical");
assert!(!root.join(format!("{}.mobiler-new", SEED_PATHS[0])).exists());
assert!(!root.join(".mobiler/base").join(SEED_PATHS[0]).exists(), "no baseline for a seed file");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn seed_files_created_even_when_theme_is_offered_as_new() {
let root = skeleton();
let theme = "Android/app/src/main/res/values/themes.xml";
fs::create_dir_all(root.join(theme).parent().unwrap()).unwrap();
fs::write(root.join(theme), "<resources><!-- mine --></resources>\n").unwrap();
upgrade_at(&root, false).unwrap();
assert_eq!(read(&root, theme), "<resources><!-- mine --></resources>\n");
assert!(!root.join(format!("{theme}.mobiler-new")).exists(), "a review copy inside res/ breaks the Android build");
assert!(root.join(format!(".mobiler/new/{theme}.mobiler-new")).exists());
for p in SEED_PATHS {
assert!(root.join(p).exists(), "{p} created although the theme was not applied");
}
let _ = fs::remove_dir_all(&root);
}
#[test]
fn existing_night_theme_is_not_overwritten_even_with_apply() {
let root = skeleton();
let night = "Android/app/src/main/res/values-night/themes.xml";
fs::create_dir_all(root.join(night).parent().unwrap()).unwrap();
fs::write(root.join(night), "<resources><!-- my night --></resources>\n").unwrap();
upgrade_at(&root, true).unwrap();
assert_eq!(read(&root, night), "<resources><!-- my night --></resources>\n");
assert!(!root.join(format!("{night}.mobiler-new")).exists());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn only_the_base_theme_file_defines_the_app_theme() {
let mut found = Vec::new();
fn walk(dir: &Dir<'_>, found: &mut Vec<String>) {
for e in dir.entries() {
match e {
include_dir::DirEntry::Dir(d) => walk(d, found),
include_dir::DirEntry::File(f) => {
let p = f.path().to_string_lossy().replace('\\', "/");
if p.starts_with("Android/app/src/main/res/") && f.contents_utf8().is_some_and(|t| t.contains("<style name=\"Theme.{{NAME}}\"")) {
found.push(p);
}
}
}
}
}
walk(&TEMPLATES, &mut found);
assert_eq!(found, ["Android/app/src/main/res/values/themes.xml"]);
let base = TEMPLATES.get_file("Android/app/src/main/res/values/themes.xml").unwrap().contents_utf8().unwrap();
assert!(base.contains("parent=\"Base.Theme.{{NAME}}\""), "the app theme must inherit the framework's Base theme");
}
#[cfg(unix)]
#[test]
fn seed_write_does_not_follow_a_symlink() {
let root = skeleton();
let outside = root.with_extension("outside");
let _ = fs::remove_file(&outside);
let seed = root.join(SEED_PATHS[0]);
fs::create_dir_all(seed.parent().unwrap()).unwrap();
std::os::unix::fs::symlink(&outside, &seed).unwrap(); let _ = upgrade_at(&root, true);
assert!(!outside.exists(), "the seed write followed a symlink out of the project");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn review_copies_of_android_resources_go_outside_res() {
assert_eq!(review_rel("Android/app/src/main/res/values/themes.xml"), ".mobiler/new/Android/app/src/main/res/values/themes.xml.mobiler-new");
assert_eq!(review_rel("Android/app/src/debug/res/xml/network_security_config.xml"), ".mobiler/new/Android/app/src/debug/res/xml/network_security_config.xml.mobiler-new");
assert_eq!(review_rel("iOS/Sources/Render.swift"), "iOS/Sources/Render.swift.mobiler-new");
assert_eq!(review_rel("Android/app/src/main/java/x/res/Core.kt"), "Android/app/src/main/java/x/res/Core.kt.mobiler-new");
assert_eq!(review_rel("Android\\app\\src\\main\\res\\values\\themes.xml"), ".mobiler/new/Android/app/src/main/res/values/themes.xml.mobiler-new");
}
#[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, true).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, ".mobiler/backup/rust-toolchain.toml"), "OLD\n", "old content backed up");
assert!(!root.join("rust-toolchain.toml.mobiler-bak").exists(), "no backup next to the file");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn merge_file_missing_anchor_still_splices_in_the_new_anchor() {
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(); let after = fs::read_to_string(&core).unwrap();
assert!(
after.contains("mobiler:plugins"),
"the new anchor landed even though the user's file predates it"
);
assert!(
!root.join("Android/app/src/main/java/dev/mobiler/demo/Core.kt.mobiler-new").exists(),
"applied in place — not left hands-off as a sidecar"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn two_way_merge_file_with_non_utf8_current_stays_hands_off() {
let root = skeleton();
let dst = root.join("f.txt");
let current: &[u8] = b"not \xFF\xFEvalid utf8 \xC0\xC0\n";
fs::write(&dst, current).unwrap();
let pristine = "template content\n// mobiler:plugins\n";
let mut r = Report::default();
let inc = two_way(Class::Merge, current, pristine, &dst, "f.txt", true, &mut r).unwrap();
assert!(!inc, "non-UTF-8 current can't be spliced — not incorporated");
assert_eq!(fs::read(&dst).unwrap(), current, "original left untouched, even with --apply");
assert!(root.join("f.txt.mobiler-new").exists(), "offered as .mobiler-new");
assert_eq!(r.merge.len(), 1);
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);
}
}