diff --git a/gpx-rs/engine/src/engine/command/file/delete.rs b/gpx-rs/engine/src/engine/command/file/delete.rs index d1aa610f7..84cc16ad6 100644 --- a/gpx-rs/engine/src/engine/command/file/delete.rs +++ b/gpx-rs/engine/src/engine/command/file/delete.rs @@ -1,11 +1,153 @@ -use crate::{Apply, CommandError, State}; +use std::rc::Rc; + +use crate::{Apply, CommandError, Selection, State}; #[derive(Debug)] pub struct Delete; impl Apply for Delete { - fn apply(self, _state: &mut State) -> Result<(), CommandError> { - // TODO - Err(CommandError::NotImplemented("delete")) + fn apply(self, state: &mut State) -> Result<(), CommandError> { + let next = match &*state.selection { + Selection::File { file_ids } => { + state.files.retain(|id, _| !file_ids.contains(id)); + state.order.0.retain(|id| !file_ids.contains(id)); + Selection::Empty + } + Selection::Track { file_id, trk_ids } => { + let file = state + .files + .get_mut(file_id) + .ok_or(CommandError::NothingToDo)?; + let file = Rc::make_mut(file); + let len = file.trk.len(); + file.trk.retain(|trk| !trk_ids.contains(&trk.id)); + if file.trk.len() == len { + return Err(CommandError::NothingToDo); + } + Selection::File { + file_ids: [*file_id].into(), + } + } + Selection::TrackSegment { + file_id, + trk_id, + trkseg_ids, + } => { + let file = state + .files + .get_mut(file_id) + .ok_or(CommandError::NothingToDo)?; + let file = Rc::make_mut(file); + let trk = file + .trk + .iter_mut() + .find(|trk| trk.id == *trk_id) + .ok_or(CommandError::NothingToDo)?; + let len = trk.trkseg.len(); + trk.trkseg.retain(|seg| !trkseg_ids.contains(&seg.id)); + if trk.trkseg.len() == len { + return Err(CommandError::NothingToDo); + } + Selection::Track { + file_id: *file_id, + trk_ids: [*trk_id].into(), + } + } + // TODO waypoints + Selection::Empty | Selection::Waypoints { .. } | Selection::Waypoint { .. } => { + return Err(CommandError::NothingToDo); + } + }; + *state.selection = next; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use crate::engine::command::fixture::Fixture; + use crate::{FileId, Load}; + + use super::*; + + fn loaded() -> (Fixture, FileId) { + let mut fx = Fixture::default(); + let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap(); + Load { data: &data }.apply(&mut fx.state()).unwrap(); + let id = fx.order.0[0]; + (fx, id) + } + + #[test] + fn test_delete_nothing_selected() { + let mut fx = Fixture::default(); + assert_eq!( + Delete.apply(&mut fx.state()), + Err(CommandError::NothingToDo) + ); + } + + #[test] + fn test_delete_selected_files() { + let (mut fx, id) = loaded(); + crate::New { name: "keep" }.apply(&mut fx.state()).unwrap(); + let keep = fx.order.0[1]; + fx.selection = Selection::File { + file_ids: HashSet::from([id]), + }; + Delete.apply(&mut fx.state()).unwrap(); + assert_eq!(fx.order.0, vec![keep]); + assert!(fx.files.contains_key(&keep) && !fx.files.contains_key(&id)); + assert!(matches!(fx.selection, Selection::Empty)); + } + + #[test] + fn test_delete_selected_tracks() { + let (mut fx, id) = loaded(); + let before = fx.files[&id].trk.len(); + let trk_id = fx.files[&id].trk[0].id; + fx.selection = Selection::Track { + file_id: id, + trk_ids: HashSet::from([trk_id]), + }; + Delete.apply(&mut fx.state()).unwrap(); + let file = &fx.files[&id]; + assert_eq!(file.trk.len(), before - 1); + assert!(file.trk.iter().all(|t| t.id != trk_id)); + assert_eq!(fx.selected_files(), HashSet::from([id])); + } + + #[test] + fn test_delete_selected_segments() { + let (mut fx, id) = loaded(); + let trk = &fx.files[&id].trk[0]; + let (trk_id, seg_id, before) = (trk.id, trk.trkseg[0].id, trk.trkseg.len()); + fx.selection = Selection::TrackSegment { + file_id: id, + trk_id, + trkseg_ids: HashSet::from([seg_id]), + }; + Delete.apply(&mut fx.state()).unwrap(); + let trk = &fx.files[&id].trk[0]; + assert_eq!(trk.trkseg.len(), before - 1); + assert!(trk.trkseg.iter().all(|s| s.id != seg_id)); + assert!( + matches!(&fx.selection, Selection::Track { trk_ids, .. } if trk_ids.contains(&trk_id)) + ); + } + + #[test] + fn test_delete_unknown_ids_is_nothing_to_do() { + let (mut fx, id) = loaded(); + fx.selection = Selection::Track { + file_id: id, + trk_ids: HashSet::from([Default::default()]), + }; + assert_eq!( + Delete.apply(&mut fx.state()), + Err(CommandError::NothingToDo) + ); } } diff --git a/gpx-rs/engine/src/engine/command/file/duplicate.rs b/gpx-rs/engine/src/engine/command/file/duplicate.rs index 846932339..ed9544837 100644 --- a/gpx-rs/engine/src/engine/command/file/duplicate.rs +++ b/gpx-rs/engine/src/engine/command/file/duplicate.rs @@ -1,11 +1,218 @@ -use crate::{Apply, CommandError, State}; +use std::{collections::HashSet, rc::Rc}; + +use crate::{Apply, CommandError, File, Selection, State, Track, TrackSegment}; #[derive(Debug)] pub struct Duplicate; impl Apply for Duplicate { - fn apply(self, _state: &mut State) -> Result<(), CommandError> { - // TODO - Err(CommandError::NotImplemented("duplicate")) + fn apply(self, state: &mut State) -> Result<(), CommandError> { + let next = match &*state.selection { + Selection::File { file_ids } => { + let mut copies = HashSet::new(); + let mut order = Vec::with_capacity(state.order.0.len() + file_ids.len()); + for id in &state.order.0 { + order.push(*id); + let Some(file) = state.files.get(id).filter(|_| file_ids.contains(id)) else { + continue; + }; + let copy = copy_file(file); + order.push(copy.id); + copies.insert(copy.id); + state.files.insert(copy.id, Rc::new(copy)); + } + if copies.is_empty() { + return Err(CommandError::NothingToDo); + } + state.order.0 = order; + Selection::File { file_ids: copies } + } + Selection::Track { file_id, trk_ids } => { + let file = state + .files + .get_mut(file_id) + .ok_or(CommandError::NothingToDo)?; + let file = Rc::make_mut(file); + let mut copies = HashSet::new(); + duplicate_after( + &mut file.trk, + |trk| trk_ids.contains(&trk.id), + |trk| { + let copy = copy_track(trk); + copies.insert(copy.id); + copy + }, + ); + if copies.is_empty() { + return Err(CommandError::NothingToDo); + } + Selection::Track { + file_id: *file_id, + trk_ids: copies, + } + } + Selection::TrackSegment { + file_id, + trk_id, + trkseg_ids, + } => { + let file = state + .files + .get_mut(file_id) + .ok_or(CommandError::NothingToDo)?; + let file = Rc::make_mut(file); + let trk = file + .trk + .iter_mut() + .find(|trk| trk.id == *trk_id) + .ok_or(CommandError::NothingToDo)?; + let mut copies = HashSet::new(); + duplicate_after( + &mut trk.trkseg, + |seg| trkseg_ids.contains(&seg.id), + |seg| { + let copy = copy_segment(seg); + copies.insert(copy.id); + copy + }, + ); + if copies.is_empty() { + return Err(CommandError::NothingToDo); + } + Selection::TrackSegment { + file_id: *file_id, + trk_id: *trk_id, + trkseg_ids: copies, + } + } + // TODO waypoints + Selection::Empty | Selection::Waypoints { .. } | Selection::Waypoint { .. } => { + return Err(CommandError::NothingToDo); + } + }; + *state.selection = next; + Ok(()) + } +} + +/// Inserts a copy right after each item matching `filter`. +fn duplicate_after( + items: &mut Vec, + filter: impl Fn(&T) -> bool, + mut copy: impl FnMut(&T) -> T, +) { + let old = std::mem::take(items); + for item in old { + let dup = filter(&item).then(|| copy(&item)); + items.push(item); + items.extend(dup); + } +} + +// Track points are shared chunks, so copies are cheap. +fn copy_segment(segment: &TrackSegment) -> TrackSegment { + let mut copy = segment.clone(); + copy.id = Default::default(); + copy +} + +fn copy_track(track: &Track) -> Track { + Track { + id: Default::default(), + trkseg: track.trkseg.iter().map(copy_segment).collect(), + ..track.clone() + } +} + +fn copy_file(file: &File) -> File { + let mut copy = File { + id: Default::default(), + trk: file.trk.iter().map(copy_track).collect(), + ..file.clone() + }; + copy.info.name = format!("{} (copy)", file.info.name); + copy +} + +#[cfg(test)] +mod tests { + use crate::engine::command::fixture::Fixture; + use crate::{FileId, Load}; + + use super::*; + + fn loaded() -> (Fixture, FileId) { + let mut fx = Fixture::default(); + let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap(); + Load { data: &data }.apply(&mut fx.state()).unwrap(); + let id = fx.order.0[0]; + (fx, id) + } + + #[test] + fn test_duplicate_nothing_selected() { + let mut fx = Fixture::default(); + assert_eq!( + Duplicate.apply(&mut fx.state()), + Err(CommandError::NothingToDo) + ); + } + + #[test] + fn test_duplicate_file() { + let (mut fx, id) = loaded(); + Duplicate.apply(&mut fx.state()).unwrap(); + assert_eq!(fx.files.len(), 2); + let copy_id = fx.order.0[1]; + assert_eq!(fx.selected_files(), [copy_id].into()); + let (orig, copy) = (&fx.files[&id], &fx.files[©_id]); + assert_ne!(copy_id, id); + assert_eq!(copy.info.name, format!("{} (copy)", orig.info.name)); + assert_eq!(copy.trk.len(), orig.trk.len()); + for (a, b) in orig.trk.iter().zip(©.trk) { + assert_ne!(a.id, b.id); + assert_eq!(a.info, b.info); + for (a, b) in a.trkseg.iter().zip(&b.trkseg) { + assert_ne!(a.id, b.id); + assert_eq!(a.len(), b.len()); + } + } + } + + #[test] + fn test_duplicate_track_inserts_after_original() { + let (mut fx, id) = loaded(); + let before = fx.files[&id].trk.len(); + let trk_id = fx.files[&id].trk[0].id; + fx.selection = Selection::Track { + file_id: id, + trk_ids: [trk_id].into(), + }; + Duplicate.apply(&mut fx.state()).unwrap(); + let file = &fx.files[&id]; + assert_eq!(file.trk.len(), before + 1); + assert_eq!(file.trk[0].id, trk_id); + assert_ne!(file.trk[1].id, trk_id); + assert!( + matches!(&fx.selection, Selection::Track { trk_ids, .. } if trk_ids.contains(&file.trk[1].id)) + ); + } + + #[test] + fn test_duplicate_segment_inserts_after_original() { + let (mut fx, id) = loaded(); + let trk = &fx.files[&id].trk[0]; + let (trk_id, seg_id, before) = (trk.id, trk.trkseg[0].id, trk.trkseg.len()); + fx.selection = Selection::TrackSegment { + file_id: id, + trk_id, + trkseg_ids: [seg_id].into(), + }; + Duplicate.apply(&mut fx.state()).unwrap(); + let trk = &fx.files[&id].trk[0]; + assert_eq!(trk.trkseg.len(), before + 1); + assert_eq!(trk.trkseg[0].id, seg_id); + assert_ne!(trk.trkseg[1].id, seg_id); + assert_eq!(trk.trkseg[0].len(), trk.trkseg[1].len()); } } diff --git a/gpx-rs/engine/src/engine/engine.rs b/gpx-rs/engine/src/engine/engine.rs index 07f3e3eb6..105f2d9cc 100644 --- a/gpx-rs/engine/src/engine/engine.rs +++ b/gpx-rs/engine/src/engine/engine.rs @@ -74,20 +74,6 @@ impl Engine { /// Brings everything derived from the files back in line with the current stack entry. fn refresh(&mut self) { let current = self.stack.current(); - match current { - Some(files) => { - self.order.0.retain(|id| files.contains_key(id)); - let known: HashSet = self.order.0.iter().copied().collect(); - self.order - .0 - .extend(files.keys().filter(|id| !known.contains(id)).copied()); - self.selection.retain_existing(files); - } - None => { - self.order.0.clear(); - self.selection = Selection::Empty; - } - } self.statistics_cache.update(current); self.statistics_buffer .update(&self.statistics_cache.get(current, &self.selection));