mod credentials;
mod registration;
use std::path::{Path, PathBuf};
use anyhow::Result;
use arora_bridge::{Bridge, DeviceInfo};
use arora_hal::HalDescription;
use arora_studio_bridge_client::firestore_support::options::{
FirebaseEmulatorOptions, FirebaseOptions,
};
use arora_studio_bridge_client::zenoh::ZenohDeviceClient;
use log::{info, warn};
use crate::device_dir;
use crate::operator::Operator;
use arora_studio_bridge_client::credentials::DeviceCredentials;
use registration::Registration;
const OWNER_LABEL: &str =
"Semio Studio owner UID(s), comma-separated — leave empty to skip (disables Studio support)";
const NAME_LABEL: &str = "Device name — leave empty for a generated name";
const MODEL_FAMILY_LABEL: &str = "Model family — optional, leave empty to skip";
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Choice {
#[default]
IfSaved,
Studio,
Local,
}
const STUDIO_VAR: &str = "ARORA_STUDIO";
#[derive(Clone, Debug, Default, clap::Args)]
pub struct StudioArgs {
#[arg(long)]
studio: bool,
#[arg(long)]
no_studio: bool,
}
impl StudioArgs {
pub fn choice(&self) -> Result<Choice> {
let studio_var =
std::env::var_os(STUDIO_VAR).map(|value| value.to_string_lossy().into_owned());
self.choice_with(studio_var.as_deref())
}
fn choice_with(&self, studio_var: Option<&str>) -> Result<Choice> {
if self.no_studio {
return Ok(Choice::Local);
}
if self.studio {
return Ok(Choice::Studio);
}
let value = studio_var.map(str::trim).unwrap_or_default();
match value.to_ascii_lowercase().as_str() {
"" | "0" | "false" | "no" | "off" => Ok(Choice::IfSaved),
"1" | "true" | "yes" | "on" => Ok(Choice::Studio),
_ => anyhow::bail!(
"{STUDIO_VAR}={value:?} is not a boolean: set it to 1, true, yes or on to ask \
for Semio Studio, or leave it unset"
),
}
}
}
#[derive(Debug)]
enum Credential {
Saved,
Absent,
Unreadable(String),
}
#[derive(Debug, PartialEq, Eq)]
enum Decision {
Asked,
Declined,
Saved(PathBuf),
Unregistered(PathBuf),
Unreadable(PathBuf, String),
}
impl Decision {
fn connects(&self) -> bool {
matches!(self, Decision::Asked | Decision::Saved(_))
}
fn log(&self) {
match self {
Decision::Asked => info!(
"Studio: connecting, as --studio asks; a device with no saved credential \
registers with Semio Studio."
),
Decision::Declined => info!("Studio: --no-studio; serving the local bridge."),
Decision::Saved(dir) => info!(
"Studio: connecting with the saved credential in {}. Pass --no-studio to serve \
the local bridge instead.",
dir.display()
),
Decision::Unregistered(dir) => info!(
"Studio: no saved credential in {}; serving the local bridge. Pass --studio to \
register this device with Semio Studio.",
dir.display()
),
Decision::Unreadable(dir, error) => warn!(
"Studio: the saved credential in {} does not read ({error}); serving the local \
bridge. Pass --studio to register this device with Semio Studio anew.",
dir.display()
),
}
}
}
fn decide(
choice: Choice,
credential: impl FnOnce() -> Result<(PathBuf, Credential)>,
) -> Result<Decision> {
Ok(match choice {
Choice::Studio => Decision::Asked,
Choice::Local => Decision::Declined,
Choice::IfSaved => match credential()? {
(dir, Credential::Saved) => Decision::Saved(dir),
(dir, Credential::Absent) => Decision::Unregistered(dir),
(dir, Credential::Unreadable(error)) => Decision::Unreadable(dir, error),
},
})
}
fn credential_from_env() -> Result<(PathBuf, Credential)> {
let dir = device_dir::from_env()?.join(credentials::STUDIO_DIR);
Ok((dir, resolved(credentials::from_env())))
}
fn resolved(credentials: Result<DeviceCredentials>) -> Credential {
match credentials {
Ok(credentials) => held(&credentials),
Err(error) => Credential::Unreadable(format!("{error:#}")),
}
}
fn held(credentials: &DeviceCredentials) -> Credential {
match credentials.refresh_token() {
Ok(Some(_)) => Credential::Saved,
Ok(None) => Credential::Absent,
Err(error) => Credential::Unreadable(error.to_string()),
}
}
pub(crate) async fn default_bridge(
frontend: &crate::operator::Frontend,
description: &HalDescription,
choice: Choice,
) -> Result<Box<dyn Bridge>> {
bridge_for(
decide(choice, credential_from_env)?,
connect_studio(frontend, description),
crate::run::local_ws_bridge(),
)
.await
}
async fn bridge_for(
decision: Decision,
studio: impl std::future::Future<Output = Result<Box<dyn Bridge>>>,
local: impl std::future::Future<Output = Result<Box<dyn Bridge>>>,
) -> Result<Box<dyn Bridge>> {
decision.log();
if decision.connects() {
studio.await
} else {
local.await
}
}
async fn connect_studio(
frontend: &crate::operator::Frontend,
description: &HalDescription,
) -> Result<Box<dyn Bridge>> {
let client = if frontend.interactive {
connect_with_operator_and_description(&*frontend.operator, description).await?
} else {
Some(connect_with_description(description).await?)
};
match client {
Some(client) => Ok(client),
None => {
info!("studio-bridge: skipped by operator (no owner) — running without Studio");
crate::run::local_ws_bridge().await
}
}
}
pub async fn connect_with_operator(operator: &dyn Operator) -> Result<Option<Box<dyn Bridge>>> {
connect_with_operator_and_description(operator, &HalDescription::default()).await
}
pub async fn connect_with_operator_and_description(
operator: &dyn Operator,
description: &HalDescription,
) -> Result<Option<Box<dyn Bridge>>> {
let saved_at = registration::path_in(&device_dir::from_env()?);
let saved = registration::load(&saved_at);
let Some(resolved) =
resolve_with_operator(operator, &env_nonempty, saved.as_ref(), description).await
else {
return Ok(None);
};
if resolved.from_saved {
log_saved_use(&resolved.info, &saved_at);
}
let credentials = credentials::from_env()?;
if let Some(answered) = &resolved.answered {
match registration::save(&saved_at, answered) {
Ok(()) => info!(
"studio: saved the registration in {}; the next run asks nothing (delete it to \
answer again)",
saved_at.display()
),
Err(e) => warn!(
"studio: could not save the registration in {}: {e}; the next run asks again",
saved_at.display()
),
}
}
Ok(Some(
build_studio_bridge(credentials, Some(resolved.info)).await?,
))
}
pub async fn connect() -> Result<Box<dyn Bridge>> {
connect_with_description(&HalDescription::default()).await
}
pub async fn connect_with_description(description: &HalDescription) -> Result<Box<dyn Bridge>> {
let info = unattended_info(&device_dir::from_env()?, description);
build_studio_bridge(credentials::from_env()?, info).await
}
pub async fn connect_with_device_dir(device_dir: &Path) -> Result<Box<dyn Bridge>> {
build_studio_bridge(
credentials::in_device_dir(device_dir)?,
unattended_info(device_dir, &HalDescription::default()),
)
.await
}
async fn build_studio_bridge(
credentials: DeviceCredentials,
info: Option<DeviceInfo>,
) -> Result<Box<dyn Bridge>> {
let firebase_options = FirebaseOptions::from_env();
let firebase_emulator_options = FirebaseEmulatorOptions::from_env();
let endpoint_override = std::env::var("STUDIO_BRIDGE_ENDPOINT")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if rustls::crypto::CryptoProvider::get_default().is_none() {
let _ = rustls::crypto::ring::default_provider().install_default();
}
let client = match endpoint_override {
Some(endpoint) => {
info!("Connecting to Semio Studio via Zenoh (endpoint: {endpoint})");
ZenohDeviceClient::new_endpoint_with_credentials(
&firebase_options,
Some(&firebase_emulator_options),
&credentials,
info,
endpoint,
)
.await
}
None => {
info!("Connecting to Semio Studio via the baked-in bridge endpoint");
ZenohDeviceClient::new_with_credentials(
&firebase_options,
Some(&firebase_emulator_options),
&credentials,
info,
)
.await
}
}
.map_err(|e| anyhow::anyhow!("failed to connect to Semio Studio via Zenoh: {e:?}"))?;
let client: Box<dyn Bridge> = Box::new(client);
Ok(client)
}
#[derive(Debug, PartialEq)]
struct Resolved {
info: DeviceInfo,
from_saved: bool,
answered: Option<Registration>,
}
async fn resolve_with_operator(
operator: &dyn Operator,
env: &dyn Fn(&str) -> Option<String>,
saved: Option<&Registration>,
description: &HalDescription,
) -> Option<Resolved> {
let mut asked = false;
let mut from_saved = false;
let owners = match (env("DEVICE_OWNERS"), saved) {
(Some(owners), _) => parse_owners(&owners),
(None, Some(saved)) => {
from_saved = true;
saved.owners.clone()
}
(None, None) => {
asked = true;
operator
.ask_text(OWNER_LABEL, false)
.await
.map(|owners| parse_owners(&owners))
.unwrap_or_default()
}
};
if owners.is_empty() {
return None;
}
let name = match (env("DEVICE_NAME"), saved) {
(Some(name), _) => name,
(None, Some(saved)) => {
from_saved = true;
saved.name.clone()
}
(None, None) => {
asked = true;
ask_nonblank(operator, NAME_LABEL)
.await
.unwrap_or_else(generated_device_name)
}
};
let mut typed_model_family = None;
let model_family = match (env("MODEL_FAMILY"), hal_field(&description.model_family)) {
(Some(model_family), _) => Some(model_family),
(None, Some(model_family)) => {
info!("studio: model family {model_family}, as the HAL describes the device");
Some(model_family)
}
(None, None) => match saved {
Some(saved) => {
from_saved |= saved.model_family.is_some();
saved.model_family.clone()
}
None => {
asked = true;
typed_model_family = ask_nonblank(operator, MODEL_FAMILY_LABEL).await;
typed_model_family.clone()
}
},
};
let answered = asked.then(|| Registration {
owners: owners.clone(),
name: name.clone(),
model_family: typed_model_family,
});
Some(Resolved {
info: DeviceInfo {
name: Some(name),
description: env("DEVICE_DESCRIPTION"),
model_family,
hardware_version: env("HARDWARE_VERSION")
.or_else(|| hal_field(&description.hardware_version)),
software_version: env("SOFTWARE_VERSION")
.or_else(|| hal_field(&description.software_version)),
owners,
},
from_saved,
answered,
})
}
fn resolve_unattended(
env: &dyn Fn(&str) -> Option<String>,
saved: Option<&Registration>,
description: &HalDescription,
) -> Option<Resolved> {
let from_env = DeviceInfo {
name: env("DEVICE_NAME"),
description: env("DEVICE_DESCRIPTION"),
model_family: env("MODEL_FAMILY"),
hardware_version: env("HARDWARE_VERSION"),
software_version: env("SOFTWARE_VERSION"),
owners: env("DEVICE_OWNERS")
.map(|owners| parse_owners(&owners))
.unwrap_or_default(),
};
if from_env == DeviceInfo::default() && saved.is_none() {
return None;
}
let mut from_saved = false;
let owners = match (from_env.owners.is_empty(), saved) {
(true, Some(saved)) => {
from_saved = true;
saved.owners.clone()
}
_ => from_env.owners,
};
let name = or_saved(
from_env.name,
saved.map(|saved| saved.name.clone()),
&mut from_saved,
);
let model_family = or_saved(
from_env
.model_family
.or_else(|| hal_field(&description.model_family)),
saved.and_then(|saved| saved.model_family.clone()),
&mut from_saved,
);
Some(Resolved {
info: DeviceInfo {
name,
description: from_env.description,
model_family,
hardware_version: from_env
.hardware_version
.or_else(|| hal_field(&description.hardware_version)),
software_version: from_env
.software_version
.or_else(|| hal_field(&description.software_version)),
owners,
},
from_saved,
answered: None,
})
}
fn or_saved(value: Option<String>, saved: Option<String>, from_saved: &mut bool) -> Option<String> {
value.or_else(|| {
*from_saved |= saved.is_some();
saved
})
}
fn unattended_info(device_dir: &Path, description: &HalDescription) -> Option<DeviceInfo> {
let saved_at = registration::path_in(device_dir);
let saved = registration::load(&saved_at);
let resolved = resolve_unattended(&env_nonempty, saved.as_ref(), description)?;
if resolved.from_saved {
log_saved_use(&resolved.info, &saved_at);
}
Some(resolved.info)
}
fn log_saved_use(info: &DeviceInfo, saved_at: &Path) {
info!(
"studio: registering as {} for owner(s) {} (saved in {}; delete it to answer again)",
info.name.as_deref().unwrap_or("(no name)"),
info.owners.join(", "),
saved_at.display()
);
}
fn parse_owners(owners: &str) -> Vec<String> {
owners
.split(',')
.map(|owner| owner.trim().to_string())
.filter(|owner| !owner.is_empty())
.collect()
}
async fn ask_nonblank(operator: &dyn Operator, label: &str) -> Option<String> {
operator
.ask_text(label, false)
.await
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn hal_field(value: &Option<String>) -> Option<String> {
value
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn env_nonempty(key: &str) -> Option<String> {
std::env::var(key)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn generated_device_name() -> String {
format!("arora-device-{}", random_device_suffix())
}
fn random_device_suffix() -> String {
use std::hash::{BuildHasher, Hasher};
let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
hasher.write_u64(std::process::id() as u64);
let mut value = hasher.finish();
const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
let mut suffix = String::with_capacity(8);
for _ in 0..8 {
suffix.push(ALPHABET[(value % 36) as usize] as char);
value /= 36;
}
suffix
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::Mutex;
use async_trait::async_trait;
use arora_bridge::AccessDecision;
use super::*;
use crate::operator::{AccessRequestSummary, AccessRuling};
#[derive(Default)]
struct Scripted {
answers: HashMap<&'static str, &'static str>,
asked: Mutex<Vec<String>>,
}
impl Scripted {
fn answering(answers: &[(&'static str, &'static str)]) -> Self {
Scripted {
answers: answers.iter().copied().collect(),
..Scripted::default()
}
}
fn asked(&self) -> Vec<String> {
self.asked.lock().unwrap().clone()
}
}
#[async_trait]
impl Operator for Scripted {
async fn ask_text(&self, label: &str, _required: bool) -> Option<String> {
self.asked.lock().unwrap().push(label.to_string());
self.answers.get(label).map(|answer| answer.to_string())
}
async fn decide_access(&self, _request: &AccessRequestSummary) -> AccessRuling {
AccessRuling {
decision: AccessDecision::Rejected,
remember: false,
}
}
}
fn env(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let vars: HashMap<String, String> = vars
.iter()
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect();
move |key| vars.get(key).cloned()
}
fn description(model_family: Option<&str>, version: Option<&str>) -> HalDescription {
HalDescription {
model_family: model_family.map(str::to_string),
hardware_version: version.map(str::to_string),
software_version: version.map(str::to_string),
}
}
fn saved(model_family: Option<&str>) -> Registration {
Registration {
owners: vec!["saved-uid".to_string()],
name: "saved-name".to_string(),
model_family: model_family.map(str::to_string),
}
}
fn strings(values: &[&str]) -> Vec<String> {
values.iter().map(|value| value.to_string()).collect()
}
#[tokio::test]
async fn a_device_registered_once_is_asked_nothing_again() {
let device =
std::env::temp_dir().join(format!("arora-studio-asked-once-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&device);
let saved_at = registration::path_in(&device);
let no_hal = HalDescription::default();
let operator = Scripted::answering(&[
(OWNER_LABEL, " uid-1, uid-2 "),
(NAME_LABEL, "pepper-lab"),
(MODEL_FAMILY_LABEL, "pepper"),
]);
let first = resolve_with_operator(&operator, &env(&[]), None, &no_hal)
.await
.expect("an owner was given");
assert_eq!(
operator.asked(),
[OWNER_LABEL, NAME_LABEL, MODEL_FAMILY_LABEL]
);
assert!(!first.from_saved);
let answered = first.answered.expect("the answers are to be saved");
registration::save(&saved_at, &answered).unwrap();
let operator = Scripted::default();
let saved = registration::load(&saved_at);
let second = resolve_with_operator(&operator, &env(&[]), saved.as_ref(), &no_hal)
.await
.expect("the saved owner registers the device");
assert!(operator.asked().is_empty(), "asked {:?}", operator.asked());
assert_eq!(second.info, first.info);
assert!(second.from_saved);
assert_eq!(second.answered, None, "nothing new to save");
std::fs::remove_dir_all(&device).unwrap();
}
#[tokio::test]
async fn a_blank_model_family_answer_is_remembered_as_none() {
let operator = Scripted::answering(&[(OWNER_LABEL, "uid"), (NAME_LABEL, "")]);
let first = resolve_with_operator(&operator, &env(&[]), None, &HalDescription::default())
.await
.unwrap();
let answered = first.answered.unwrap();
assert!(
answered.name.starts_with("arora-device-"),
"a blank name is generated, and the generated one saved: {}",
answered.name
);
assert_eq!(answered.model_family, None);
let operator = Scripted::default();
let second = resolve_with_operator(
&operator,
&env(&[]),
Some(&answered),
&HalDescription::default(),
)
.await
.unwrap();
assert!(operator.asked().is_empty(), "asked {:?}", operator.asked());
assert_eq!(second.info.name, Some(answered.name));
assert_eq!(second.info.model_family, None);
}
#[tokio::test]
async fn an_empty_owner_declines_studio_and_saves_nothing() {
let operator = Scripted::answering(&[(OWNER_LABEL, " , ")]);
let resolved =
resolve_with_operator(&operator, &env(&[]), None, &HalDescription::default()).await;
assert_eq!(resolved, None);
assert_eq!(operator.asked(), [OWNER_LABEL], "nothing else is asked");
}
#[tokio::test]
async fn the_environment_wins_over_the_saved_registration() {
let operator = Scripted::default();
let resolved = resolve_with_operator(
&operator,
&env(&[
("DEVICE_OWNERS", "env-uid"),
("DEVICE_NAME", "env-name"),
("MODEL_FAMILY", "env-model"),
]),
Some(&saved(Some("saved-model"))),
&description(Some("hal-model"), None),
)
.await
.unwrap();
assert!(operator.asked().is_empty());
assert_eq!(resolved.info.owners, strings(&["env-uid"]));
assert_eq!(resolved.info.name.as_deref(), Some("env-name"));
assert_eq!(resolved.info.model_family.as_deref(), Some("env-model"));
assert!(!resolved.from_saved);
assert_eq!(resolved.answered, None);
}
#[tokio::test]
async fn the_hal_gives_the_model_family_and_versions_unless_the_environment_does() {
let operator = Scripted::answering(&[(OWNER_LABEL, "uid"), (NAME_LABEL, "nao")]);
let resolved = resolve_with_operator(
&operator,
&env(&[("SOFTWARE_VERSION", "env-sw")]),
None,
&description(Some("nao"), Some("hal-v")),
)
.await
.unwrap();
assert_eq!(operator.asked(), [OWNER_LABEL, NAME_LABEL]);
assert_eq!(resolved.info.model_family.as_deref(), Some("nao"));
assert_eq!(resolved.info.hardware_version.as_deref(), Some("hal-v"));
assert_eq!(resolved.info.software_version.as_deref(), Some("env-sw"));
assert_eq!(resolved.answered.unwrap().model_family, None);
let operator = Scripted::default();
let resolved = resolve_with_operator(
&operator,
&env(&[]),
Some(&saved(Some("saved-model"))),
&description(Some("pepper"), None),
)
.await
.unwrap();
assert!(operator.asked().is_empty());
assert_eq!(resolved.info.model_family.as_deref(), Some("pepper"));
assert_eq!(resolved.info.owners, strings(&["saved-uid"]));
}
#[tokio::test]
async fn the_operator_is_asked_only_for_what_nothing_gives() {
let operator = Scripted::answering(&[(NAME_LABEL, "typed-name")]);
let resolved = resolve_with_operator(
&operator,
&env(&[("DEVICE_OWNERS", "env-uid"), ("MODEL_FAMILY", "env-model")]),
None,
&HalDescription::default(),
)
.await
.unwrap();
assert_eq!(operator.asked(), [NAME_LABEL]);
assert_eq!(
resolved.answered,
Some(Registration {
owners: strings(&["env-uid"]),
name: "typed-name".to_string(),
model_family: None,
}),
"the resolved owners and name are saved, an env model family is not"
);
}
fn studio_dir() -> PathBuf {
PathBuf::from("/devices/bench/studio")
}
fn finding(credential: Credential) -> impl FnOnce() -> Result<(PathBuf, Credential)> {
move || Ok((studio_dir(), credential))
}
fn not_looked_up() -> Result<(PathBuf, Credential)> {
panic!("the credential is looked up although the run said what it asks")
}
#[test]
fn no_credential_and_no_flag_serve_the_local_bridge() {
let decision = decide(Choice::IfSaved, finding(Credential::Absent)).unwrap();
assert_eq!(decision, Decision::Unregistered(studio_dir()));
assert!(!decision.connects());
}
#[test]
fn a_saved_credential_connects() {
let decision = decide(Choice::IfSaved, finding(Credential::Saved)).unwrap();
assert_eq!(decision, Decision::Saved(studio_dir()));
assert!(decision.connects());
}
#[test]
fn a_credential_that_does_not_read_serves_the_local_bridge() {
let decision = decide(
Choice::IfSaved,
finding(Credential::Unreadable("damaged".to_string())),
)
.unwrap();
assert_eq!(
decision,
Decision::Unreadable(studio_dir(), "damaged".to_string())
);
assert!(!decision.connects());
}
#[test]
fn no_studio_serves_the_local_bridge_whatever_the_device_holds() {
let decision = decide(Choice::Local, not_looked_up).unwrap();
assert_eq!(decision, Decision::Declined);
assert!(!decision.connects());
}
#[test]
fn studio_connects_whatever_the_device_holds() {
let decision = decide(Choice::Studio, not_looked_up).unwrap();
assert_eq!(decision, Decision::Asked);
assert!(decision.connects());
}
#[test]
fn a_lookup_that_fails_fails_the_start() {
let lookup = || Err(anyhow::anyhow!("no per-user data directory"));
assert!(decide(Choice::IfSaved, lookup).is_err());
}
#[test]
fn a_saved_credential_is_a_refresh_token_that_reads() {
let dir = std::env::temp_dir().join(format!("arora-studio-held-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let credentials = DeviceCredentials::in_dir(&dir).unwrap();
assert!(matches!(held(&credentials), Credential::Absent));
credentials.save_refresh_token("token").unwrap();
assert!(matches!(held(&credentials), Credential::Saved));
std::fs::remove_file(dir.join("key")).unwrap();
assert!(matches!(held(&credentials), Credential::Unreadable(_)));
std::fs::remove_dir_all(&dir).unwrap();
}
#[derive(Debug, clap::Parser)]
struct Runner {
#[command(flatten)]
studio: StudioArgs,
}
fn choice_of(args: &[&str], studio_var: Option<&str>) -> Result<Choice> {
use clap::Parser;
Runner::try_parse_from(std::iter::once("runner").chain(args.iter().copied()))
.expect("the Studio options parse")
.studio
.choice_with(studio_var)
}
#[test]
fn the_flags_say_what_the_run_asks_no_studio_winning() {
let choice = |args: &[&str]| choice_of(args, None).unwrap();
assert_eq!(choice(&[]), Choice::IfSaved);
assert_eq!(choice(&["--studio"]), Choice::Studio);
assert_eq!(choice(&["--no-studio"]), Choice::Local);
assert_eq!(choice(&["--studio", "--no-studio"]), Choice::Local);
assert_eq!(choice(&["--no-studio", "--studio"]), Choice::Local);
}
#[test]
fn arora_studio_is_a_boolean_blank_asking_nothing() {
for yes in ["1", "true", "TRUE", "yes", "On", " 1 "] {
assert_eq!(
choice_of(&[], Some(yes)).unwrap(),
Choice::Studio,
"{yes:?}"
);
}
for no in ["", " ", "0", "false", "no", "OFF"] {
assert_eq!(choice_of(&[], Some(no)).unwrap(), Choice::IfSaved, "{no:?}");
}
let error = choice_of(&[], Some("maybe")).unwrap_err().to_string();
assert!(error.contains("ARORA_STUDIO=\"maybe\""), "{error}");
}
#[test]
fn the_flags_win_over_arora_studio() {
assert_eq!(
choice_of(&["--no-studio"], Some("1")).unwrap(),
Choice::Local
);
assert_eq!(
choice_of(&["--no-studio"], Some("maybe")).unwrap(),
Choice::Local
);
assert_eq!(choice_of(&["--studio"], Some("0")).unwrap(), Choice::Studio);
}
#[test]
fn a_run_asks_nothing_unless_told() {
assert_eq!(Choice::default(), Choice::IfSaved);
assert_eq!(crate::Arora::builder().studio, Choice::IfSaved);
let builder = crate::Arora::builder().with_studio(Choice::Local);
assert_eq!(builder.studio, Choice::Local);
}
#[test]
fn credentials_that_cannot_be_resolved_do_not_read() {
let credential = resolved(Err(anyhow::anyhow!("IDENTITY_FILE names nothing")));
assert!(
matches!(&credential, Credential::Unreadable(error) if error.contains("IDENTITY_FILE")),
"{credential:?}"
);
}
fn bridge_for_(decision: Decision) -> String {
let studio = async { Err(anyhow::anyhow!("studio")) };
let local = async { Err(anyhow::anyhow!("local")) };
let ran = futures::executor::block_on(bridge_for(decision, studio, local));
ran.err()
.expect("the futures fail, naming themselves")
.to_string()
}
#[test]
fn only_a_decision_that_connects_runs_the_studio_connection() {
assert_eq!(bridge_for_(Decision::Asked), "studio");
assert_eq!(bridge_for_(Decision::Saved(studio_dir())), "studio");
assert_eq!(bridge_for_(Decision::Declined), "local");
assert_eq!(bridge_for_(Decision::Unregistered(studio_dir())), "local");
assert_eq!(
bridge_for_(Decision::Unreadable(studio_dir(), "damaged".to_string())),
"local"
);
}
#[test]
fn unattended_nothing_set_nothing_saved_registers_nothing() {
assert_eq!(
resolve_unattended(&env(&[]), None, &description(Some("nao"), Some("v"))),
None,
"the HAL's description alone registers nothing"
);
}
#[test]
fn unattended_a_saved_registration_registers_the_device() {
let resolved = resolve_unattended(
&env(&[]),
Some(&saved(Some("saved-model"))),
&description(None, Some("hal-v")),
)
.unwrap();
assert!(resolved.from_saved);
assert_eq!(
resolved.info,
DeviceInfo {
name: Some("saved-name".to_string()),
description: None,
model_family: Some("saved-model".to_string()),
hardware_version: Some("hal-v".to_string()),
software_version: Some("hal-v".to_string()),
owners: strings(&["saved-uid"]),
}
);
}
#[test]
fn unattended_the_environment_then_the_hal_then_the_saved_registration() {
let resolved = resolve_unattended(
&env(&[("DEVICE_NAME", "env-name"), ("HARDWARE_VERSION", "env-hw")]),
Some(&saved(Some("saved-model"))),
&description(Some("hal-model"), Some("hal-v")),
)
.unwrap();
assert_eq!(
resolved.info,
DeviceInfo {
name: Some("env-name".to_string()),
description: None,
model_family: Some("hal-model".to_string()),
hardware_version: Some("env-hw".to_string()),
software_version: Some("hal-v".to_string()),
owners: strings(&["saved-uid"]),
}
);
}
#[test]
fn unattended_without_a_saved_registration_is_the_environment_s() {
let resolved = resolve_unattended(
&env(&[("DEVICE_OWNERS", "a, b"), ("DEVICE_NAME", "n")]),
None,
&HalDescription::default(),
)
.unwrap();
assert!(!resolved.from_saved);
assert_eq!(
resolved.info,
DeviceInfo {
name: Some("n".to_string()),
owners: strings(&["a", "b"]),
..DeviceInfo::default()
}
);
}
}