Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions src/group_editor.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
//! Talk-group edits and persistence, independent of terminal presentation.

use std::collections::BTreeSet;
use std::path::PathBuf;

use crate::groups::{TalkGroup, normalize_name, save};

pub(crate) struct GroupEditor {
pub(crate) groups: Vec<TalkGroup>,
pub(crate) persistence_error: Option<String>,
pub(crate) error: Option<String>,
groups_path: PathBuf,
}

impl GroupEditor {
pub(crate) fn new(groups_path: PathBuf, groups: Vec<TalkGroup>) -> Self {
Self {
groups,
persistence_error: None,
error: None,
groups_path,
}
}

pub(crate) fn add(&mut self, name: &str) -> bool {
let name = name.trim();
if name.is_empty() {
self.error = Some("Talk-group name cannot be empty.".into());
return false;
}
if self
.groups
.iter()
.any(|group| normalize_name(&group.name) == normalize_name(name))
{
self.error = Some(format!("A talk group named {name:?} already exists."));
return false;
}
self.groups.push(TalkGroup {
name: name.to_string(),
members: BTreeSet::new(),
});
self.persist();
true
}

pub(crate) fn remove(&mut self, index: usize) -> bool {
if index >= self.groups.len() {
return false;
}
self.groups.remove(index);
self.persist();
true
}

pub(crate) fn toggle_member(&mut self, index: usize, address: String) -> bool {
let Some(group) = self.groups.get_mut(index) else {
return false;
};
if !group.members.remove(&address) {
group.members.insert(address);
}
self.persist();
true
}

fn persist(&mut self) {
match save(&self.groups_path, &self.groups) {
Ok(()) => {
self.error = None;
self.persistence_error = None;
}
Err(error) => self.persistence_error = Some(error),
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::groups::load;

fn path(label: &str) -> PathBuf {
std::env::temp_dir()
.join(format!(
"bt-intercom-group-editor-{label}-{}",
std::process::id()
))
.join("talk-groups.json")
}

#[test]
fn edits_persist_and_duplicate_names_use_persistence_normalization() {
let path = path("unicode");
let _ = std::fs::remove_dir_all(path.parent().unwrap());
let mut editor = GroupEditor::new(
path.clone(),
vec![TalkGroup {
name: "équipe".into(),
members: BTreeSet::new(),
}],
);

assert!(!editor.add("Équipe"));
assert!(editor.error.as_deref().unwrap().contains("already exists"));
assert!(editor.add(" Dispatch "));
assert_eq!(editor.groups[1].name, "Dispatch");
assert!(editor.toggle_member(1, "AA:BB:CC:DD:EE:01".into()));
assert!(editor.remove(0));
assert_eq!(load(&path).unwrap(), editor.groups);

let _ = std::fs::remove_dir_all(path.parent().unwrap());
}

#[test]
fn persistence_errors_are_reported_without_discarding_edits() {
let path = path("failure");
let directory = path.parent().unwrap();
std::fs::create_dir_all(directory).unwrap();
std::fs::write(directory.join("not-a-directory"), "file").unwrap();
let mut editor = GroupEditor::new(
directory.join("not-a-directory").join("groups.json"),
Vec::new(),
);

assert!(editor.add("Dispatch"));

assert_eq!(editor.groups.len(), 1);
assert!(editor.persistence_error.is_some());
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
}
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ mod atomic_file;
mod bluez;
mod dashboard;
mod device_status;
mod group_editor;
mod groups;
mod network;
mod pair_button;
Expand Down
25 changes: 18 additions & 7 deletions src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ use crate::worker::Worker;

type Execute = Box<dyn FnMut(&[&str]) -> Result<String, String>>;

struct InspectionOutcome {
changed: bool,
error: Option<String>,
}

pub(crate) fn guard_half_update<T>(
mode: Mode,
update: &Result<T, String>,
Expand Down Expand Up @@ -362,13 +367,13 @@ impl Session {
}
let update = self.update_route_set(&sources, transport_ready);
guard_half_update(self.config.mode, &update, || self.router.close())?;
let (changed, inspect_error) = self.inspect_and_confirm()?;
if changed {
let inspection = self.inspect_and_confirm()?;
if inspection.changed {
self.router.close();
self.links.clear();
}
self.report_update(update, config_error, inspect_error);
self.last_update = (!changed).then(Instant::now);
self.report_update(update, config_error, inspection.error);
self.last_update = (!inspection.changed).then(Instant::now);
Ok(())
}

Expand Down Expand Up @@ -409,13 +414,16 @@ impl Session {
}
}

fn inspect_and_confirm(&mut self) -> Result<(bool, Option<String>), String> {
fn inspect_and_confirm(&mut self) -> Result<InspectionOutcome, String> {
let (current, active) = match self.router.inspect_owned() {
Ok(snapshot) => snapshot,
Err(error) => {
self.headsets.clear();
self.links.clear();
return Ok((false, Some(error)));
return Ok(InspectionOutcome {
changed: false,
error: Some(error),
});
}
};
self.headsets = current;
Expand Down Expand Up @@ -460,7 +468,10 @@ impl Session {
&self.stopped,
);
}
Ok((changed, None))
Ok(InspectionOutcome {
changed,
error: None,
})
}

fn report_update(
Expand Down
38 changes: 19 additions & 19 deletions src/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,7 @@ pub(crate) fn controller_warnings(
devices: &BTreeMap<String, Device>,
sco_limit: usize,
) -> Vec<String> {
let mut controllers: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for (address, device) in devices {
controllers
.entry(&device.controller)
.or_default()
.push(address);
}
controllers
addresses_by_controller(devices.iter())
.into_iter()
.filter(|(_, addresses)| addresses.len() > sco_limit)
.map(|(controller, addresses)| {
Expand Down Expand Up @@ -142,6 +135,19 @@ impl Device {
}
}

fn addresses_by_controller<'a>(
devices: impl IntoIterator<Item = (&'a String, &'a Device)>,
) -> BTreeMap<&'a str, Vec<&'a str>> {
let mut controllers = BTreeMap::new();
for (address, device) in devices {
controllers
.entry(device.controller.as_str())
.or_insert_with(Vec::new)
.push(address.as_str());
}
controllers
}

fn identifier(value: &Value) -> Option<String> {
value
.as_str()
Expand Down Expand Up @@ -465,17 +471,11 @@ fn validate_source_capacity(
devices: &BTreeMap<String, Device>,
sco_limit: usize,
) -> Result<(), String> {
let mut controllers: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for address in sources {
let Some(device) = devices.get(address) else {
continue;
};
controllers
.entry(&device.controller)
.or_default()
.push(address);
}
for (controller, addresses) in controllers {
for (controller, addresses) in addresses_by_controller(
sources
.iter()
.filter_map(|address| devices.get(address).map(|device| (address, device))),
) {
if addresses.len() > sco_limit {
return Err(format!(
"SCO capacity conflict on {controller}: {} sources ({}) exceed configured --sco-limit {sco_limit}",
Expand Down
Loading