use serde::Serialize;
#[derive(Debug, Clone, Default)]
pub struct Facts {
pub simctl: Option<SimctlFacts>,
pub registry: Option<RegistryFacts>,
pub runner_up: bool,
pub capture_server_up: bool,
}
#[derive(Debug, Clone, Default)]
pub struct SimctlFacts {
pub available_runtimes: usize,
pub available_devices: usize,
}
#[derive(Debug, Clone, Default)]
pub struct RegistryFacts {
pub aliases: usize,
pub first_alias: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
Ok,
Blocked,
Skipped,
}
#[derive(Debug, Clone, Serialize)]
pub struct Check {
pub id: &'static str,
pub status: Status,
pub detail: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct NextStep {
pub command: String,
pub reason: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct Readiness {
pub ready: bool,
pub checks: Vec<Check>,
pub next: Option<NextStep>,
}
pub const PLATFORM_NOTE: &str =
"smix drives iOS Simulators and Android emulators. Physical devices are out of scope.";
pub fn assess(facts: &Facts) -> Readiness {
let mut checks = Vec::new();
let Some(simctl) = &facts.simctl else {
checks.push(Check {
id: "simctl",
status: Status::Blocked,
detail: "xcrun simctl could not be run — the Xcode command-line tools are \
not installed, or no Xcode is selected"
.into(),
});
return blocked(
checks,
"xcode-select --install",
"installs the command-line tools smix drives the simulator through",
2,
);
};
checks.push(Check {
id: "simctl",
status: Status::Ok,
detail: "xcrun simctl reachable".into(),
});
if simctl.available_runtimes == 0 {
checks.push(Check {
id: "runtime",
status: Status::Blocked,
detail: "no available simulator runtime — Xcode is installed but carries no \
usable iOS runtime"
.into(),
});
return blocked(
checks,
"xcodebuild -downloadPlatform iOS",
"downloads an iOS runtime, without which no simulator can boot",
1,
);
}
checks.push(Check {
id: "runtime",
status: Status::Ok,
detail: format!("{} runtimes available", simctl.available_runtimes),
});
if simctl.available_devices == 0 {
checks.push(Check {
id: "device",
status: Status::Blocked,
detail: "no available simulator — a runtime is installed but no device uses it".into(),
});
return blocked(
checks,
"xcrun simctl create smix-dev 'iPhone 17 Pro' <runtime-id>",
"creates a simulator for smix to register and drive",
1,
);
}
checks.push(Check {
id: "device",
status: Status::Ok,
detail: format!("{} simulators available", simctl.available_devices),
});
let registered = match &facts.registry {
Some(r) if r.aliases > 0 => r,
_ => {
checks.push(Check {
id: "registry",
status: Status::Blocked,
detail: "no device registered in .smix — every smix command takes an \
explicit device, and an alias is where that comes from"
.into(),
});
return blocked(
checks,
"smix init",
"registers a simulator under an alias and creates the .smix registry",
1,
);
}
};
checks.push(Check {
id: "registry",
status: Status::Ok,
detail: format!("{} device(s) registered in .smix", registered.aliases),
});
let alias = registered
.first_alias
.clone()
.unwrap_or_else(|| "dev".into());
if !facts.runner_up {
checks.push(Check {
id: "runner",
status: Status::Blocked,
detail: "no runner answering — sense and act need one on the device".into(),
});
let (flags, note) = if facts.capture_server_up {
("", "")
} else {
(
" --no-capture",
" (--no-capture because the capture server is not running; \
start smix-server first to record the session)",
)
};
return blocked(
checks,
format!("smix capsule up {alias} --bundle <your.bundle.id>{flags}"),
format!(
"boots the device and starts the runner that carries every tap and query{note}"
),
0,
);
}
checks.push(Check {
id: "runner",
status: Status::Ok,
detail: "runner answering".into(),
});
Readiness {
ready: true,
checks,
next: None,
}
}
fn blocked(
mut checks: Vec<Check>,
command: impl Into<String>,
reason: impl Into<String>,
remaining: usize,
) -> Readiness {
const ORDER: [&str; 5] = ["simctl", "runtime", "device", "registry", "runner"];
let done = checks.len();
for id in ORDER.iter().skip(done).take(remaining) {
checks.push(Check {
id,
status: Status::Skipped,
detail: "not checked — an earlier step has to pass first".into(),
});
}
Readiness {
ready: false,
checks,
next: Some(NextStep {
command: command.into(),
reason: reason.into(),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn simctl_ok() -> SimctlFacts {
SimctlFacts {
available_runtimes: 1,
available_devices: 3,
}
}
#[test]
fn readiness_sends_a_fresh_workspace_to_init() {
let r = assess(&Facts {
simctl: Some(simctl_ok()),
registry: None,
runner_up: false,
capture_server_up: false,
});
assert!(!r.ready);
let next = r
.next
.expect("a blocked verdict must name the next command");
assert_eq!(next.command, "smix init");
assert!(
next.reason.contains(".smix"),
"the reason should name what init creates: {}",
next.reason
);
}
#[test]
fn readiness_sends_a_registered_workspace_to_capsule_up() {
let r = assess(&Facts {
simctl: Some(simctl_ok()),
registry: Some(RegistryFacts {
aliases: 1,
first_alias: Some("dev".into()),
}),
runner_up: false,
capture_server_up: false,
});
assert!(!r.ready);
let next = r.next.expect("still blocked on the runner");
assert!(
next.command.starts_with("smix capsule up dev"),
"next should drive the registered alias: {}",
next.command
);
}
#[test]
fn a_missing_toolchain_is_reported_before_anything_it_would_break() {
let r = assess(&Facts {
simctl: None,
registry: None,
runner_up: false,
capture_server_up: false,
});
let next = r.next.expect("blocked");
assert!(
next.command.contains("xcode-select"),
"got: {}",
next.command
);
assert!(!next.command.contains("smix init"));
assert_eq!(r.checks[0].status, Status::Blocked);
assert!(
r.checks.iter().skip(1).all(|c| c.status == Status::Skipped),
"checks below a blocked one are not verdicts about the machine"
);
}
#[test]
fn everything_satisfied_leaves_nothing_to_run() {
let r = assess(&Facts {
simctl: Some(simctl_ok()),
registry: Some(RegistryFacts {
aliases: 2,
first_alias: Some("dev".into()),
}),
runner_up: true,
capture_server_up: true,
});
assert!(r.ready);
assert!(r.next.is_none(), "a ready machine has no next command");
assert!(r.checks.iter().all(|c| c.status == Status::Ok));
}
#[test]
fn a_registry_file_naming_no_device_is_not_a_registry() {
let r = assess(&Facts {
simctl: Some(simctl_ok()),
registry: Some(RegistryFacts {
aliases: 0,
first_alias: None,
}),
runner_up: false,
capture_server_up: false,
});
assert_eq!(r.next.expect("blocked").command, "smix init");
}
#[test]
fn readiness_serializes_with_the_fields_a_script_reads() {
let r = assess(&Facts {
simctl: Some(simctl_ok()),
registry: None,
runner_up: false,
capture_server_up: false,
});
let v: serde_json::Value = serde_json::to_value(&r).expect("serialize");
assert_eq!(v["ready"], serde_json::Value::Bool(false));
assert_eq!(v["next"]["command"], "smix init");
assert_eq!(v["checks"][0]["id"], "simctl");
assert_eq!(v["checks"][0]["status"], "ok");
}
#[test]
fn the_suggested_command_works_on_the_machine_it_is_suggested_on() {
let without = assess(&Facts {
simctl: Some(simctl_ok()),
registry: Some(RegistryFacts {
aliases: 1,
first_alias: Some("dev".into()),
}),
runner_up: false,
capture_server_up: false,
});
let cmd = without.next.expect("blocked").command;
assert!(cmd.contains("--no-capture"), "got: {cmd}");
let with = assess(&Facts {
simctl: Some(simctl_ok()),
registry: Some(RegistryFacts {
aliases: 1,
first_alias: Some("dev".into()),
}),
runner_up: false,
capture_server_up: true,
});
assert!(!with.next.expect("blocked").command.contains("--no-capture"));
}
#[test]
fn the_platform_note_does_not_claim_ios_only() {
assert!(!PLATFORM_NOTE.contains("iOS Simulator only"));
assert!(PLATFORM_NOTE.contains("Android"));
assert!(PLATFORM_NOTE.contains("Physical devices are out of scope"));
}
}