1use std::collections::HashMap;
2
3use crate::event_log::EventLog;
4use crate::tokenizer::{is_key_value, parse_key_value, tokenize};
5
6pub trait SessionHooks {
8 type Model;
10 type Event: Clone;
12
13 fn on_new(&self, params: &HashMap<String, String>) -> Result<Self::Model, String>;
15 fn on_open(&self, path: &str) -> Result<Self::Model, String>;
17 fn on_save(&self, model: &Self::Model, path: &str) -> Result<(), String>;
19 fn on_rebuild_indices(&self, model: &mut Self::Model);
21 fn get_digest(&self, model: &Self::Model) -> String;
23 fn reverse(&self, event: &Self::Event, model: &mut Self::Model);
25 fn replay(&self, event: &Self::Event, model: &mut Self::Model);
27}
28
29pub struct Session<H: SessionHooks> {
32 pub model: Option<H::Model>,
33 pub file_path: String,
34 pub log: EventLog<H::Event>,
35 hooks: H,
36}
37
38impl<H: SessionHooks> Session<H> {
39 pub fn new(hooks: H) -> Self {
41 Session {
42 model: None,
43 file_path: String::new(),
44 log: EventLog::new(),
45 hooks,
46 }
47 }
48
49 pub fn dispatch(&mut self, action: &str) -> String {
53 let tokens = tokenize(action);
54 if tokens.is_empty() {
55 return "empty action".to_string();
56 }
57
58 let cmd = tokens[0].to_lowercase();
59
60 match cmd.as_str() {
61 "new" => self.dispatch_new(&tokens),
62 "open" => self.dispatch_open(&tokens),
63 "save" => self.dispatch_save(&tokens),
64 "checkpoint" => self.dispatch_checkpoint(&tokens),
65 "undo" => self.dispatch_undo(&tokens),
66 "redo" => self.dispatch_redo(),
67 "status" => self.dispatch_status(),
68 "close" => self.dispatch_close(),
69 _ => format!("unknown session action {cmd:?}"),
70 }
71 }
72
73 fn dispatch_new(&mut self, tokens: &[String]) -> String {
74 let mut params = HashMap::new();
75 let mut positionals = Vec::new();
76 for token in &tokens[1..] {
77 if is_key_value(token) {
78 let (key, value) = parse_key_value(token);
79 params.insert(key, value);
80 } else {
81 positionals.push(token.clone());
82 }
83 }
84 if !positionals.is_empty() {
85 params.insert("title".to_string(), positionals[0].clone());
86 }
87
88 match self.hooks.on_new(¶ms) {
89 Ok(model) => {
90 self.model = Some(model);
91 self.log = EventLog::new();
92 self.file_path = String::new();
93
94 let title = params
95 .get("title")
96 .filter(|t| !t.is_empty())
97 .cloned()
98 .unwrap_or_else(|| "Untitled".to_string());
99 format!("new {title:?} created")
100 }
101 Err(e) => format!("error: {e}"),
102 }
103 }
104
105 fn dispatch_open(&mut self, tokens: &[String]) -> String {
106 if tokens.len() < 2 {
107 return "open requires a file path".to_string();
108 }
109 let path = &tokens[1];
110 match self.hooks.on_open(path) {
111 Ok(model) => {
112 self.model = Some(model);
113 self.log = EventLog::new();
114 self.file_path = path.clone();
115 format!("opened {path:?}")
116 }
117 Err(e) => format!("error: {e}"),
118 }
119 }
120
121 fn dispatch_save(&mut self, tokens: &[String]) -> String {
122 let Some(model) = self.model.as_ref() else {
123 return "error: no model to save".to_string();
124 };
125
126 let mut save_path = self.file_path.clone();
127 for token in &tokens[1..] {
128 if is_key_value(token) {
129 let (key, value) = parse_key_value(token);
130 if key == "as" {
131 save_path = value;
132 }
133 }
134 }
135 if save_path.is_empty() {
136 return "error: no file path. Use save as:./file".to_string();
137 }
138 match self.hooks.on_save(model, &save_path) {
139 Ok(()) => {
140 self.file_path = save_path.clone();
141 format!("saved {save_path:?}")
142 }
143 Err(e) => format!("error: {e}"),
144 }
145 }
146
147 fn dispatch_checkpoint(&mut self, tokens: &[String]) -> String {
148 if tokens.len() < 2 {
149 return "checkpoint requires a name".to_string();
150 }
151 let name = &tokens[1];
152 self.log.checkpoint(name);
153 format!("checkpoint {name:?} created")
154 }
155
156 fn dispatch_undo(&mut self, tokens: &[String]) -> String {
157 if self.model.is_none() {
158 return "nothing to undo".to_string();
159 }
160
161 if let Some(name) = tokens.get(1).and_then(|t| t.strip_prefix("to:")) {
163 if name.is_empty() {
164 return "undo to: requires a checkpoint name".to_string();
165 }
166 match self.log.undo_to(name) {
167 Ok(events) => {
168 let count = events.len();
169 let model = self.model.as_mut().unwrap();
170 for ev in &events {
171 self.hooks.reverse(ev, model);
172 }
173 self.hooks.on_rebuild_indices(model);
174 return format!(
175 "undone {count} event{} to checkpoint {name:?}",
176 plural(count),
177 );
178 }
179 Err(_) => {
180 return format!("cannot undo to {name:?}");
181 }
182 }
183 }
184
185 let events = self.log.undo(1);
186 if events.is_empty() {
187 return "nothing to undo".to_string();
188 }
189 let count = events.len();
190 let model = self.model.as_mut().unwrap();
191 for ev in &events {
192 self.hooks.reverse(ev, model);
193 }
194 self.hooks.on_rebuild_indices(model);
195 format!("undone {count} event{}", plural(count))
196 }
197
198 fn dispatch_redo(&mut self) -> String {
199 if self.model.is_none() {
200 return "nothing to redo".to_string();
201 }
202 let events = self.log.redo(1);
203 if events.is_empty() {
204 return "nothing to redo".to_string();
205 }
206 let count = events.len();
207 let model = self.model.as_mut().unwrap();
208 for ev in &events {
209 self.hooks.replay(ev, model);
210 }
211 self.hooks.on_rebuild_indices(model);
212 format!("redone {count} event{}", plural(count))
213 }
214
215 fn dispatch_status(&self) -> String {
216 "status: placeholder".to_string()
217 }
218
219 fn dispatch_close(&mut self) -> String {
220 "close: placeholder".to_string()
221 }
222}
223
224fn plural(n: usize) -> &'static str {
225 if n != 1 {
226 "s"
227 } else {
228 ""
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[derive(Debug)]
237 struct MockModel {
238 title: String,
239 data: Vec<String>,
240 }
241
242 #[derive(Clone, Debug)]
243 #[allow(dead_code)]
244 struct MockEvent(String);
245
246 struct MockHooks {
247 open_error: Option<String>,
248 save_error: Option<String>,
249 }
250
251 impl MockHooks {
252 fn new() -> Self {
253 MockHooks {
254 open_error: None,
255 save_error: None,
256 }
257 }
258 }
259
260 impl SessionHooks for MockHooks {
261 type Model = MockModel;
262 type Event = MockEvent;
263
264 fn on_new(&self, params: &HashMap<String, String>) -> Result<MockModel, String> {
265 let title = params
266 .get("title")
267 .filter(|t| !t.is_empty())
268 .cloned()
269 .unwrap_or_else(|| "Untitled".to_string());
270 Ok(MockModel {
271 title,
272 data: Vec::new(),
273 })
274 }
275
276 fn on_open(&self, path: &str) -> Result<MockModel, String> {
277 if let Some(ref e) = self.open_error {
278 return Err(e.clone());
279 }
280 Ok(MockModel {
281 title: path.to_string(),
282 data: vec!["loaded".to_string()],
283 })
284 }
285
286 fn on_save(&self, _model: &MockModel, _path: &str) -> Result<(), String> {
287 if let Some(ref e) = self.save_error {
288 return Err(e.clone());
289 }
290 Ok(())
291 }
292
293 fn on_rebuild_indices(&self, _model: &mut MockModel) {}
294
295 fn get_digest(&self, model: &MockModel) -> String {
296 format!("[{}: {} items]", model.title, model.data.len())
297 }
298
299 fn reverse(&self, _event: &MockEvent, _model: &mut MockModel) {}
300 fn replay(&self, _event: &MockEvent, _model: &mut MockModel) {}
301 }
302
303 fn create_mock_session() -> Session<MockHooks> {
304 Session::new(MockHooks::new())
305 }
306
307 #[test]
308 fn test_session_new_with_title() {
309 let mut session = create_mock_session();
310 let result = session.dispatch(r#"new "My Song""#);
311 assert_eq!(result, r#"new "My Song" created"#);
312 assert!(session.model.is_some());
313 let m = session.model.as_ref().unwrap();
314 assert_eq!(m.title, "My Song");
315 }
316
317 #[test]
318 fn test_session_new_with_params() {
319 let mut session = create_mock_session();
320 session.dispatch(r#"new "Test" tempo:120"#);
321 let m = session.model.as_ref().unwrap();
323 assert_eq!(m.title, "Test");
324 }
325
326 #[test]
327 fn test_session_new_default_title() {
328 let mut session = create_mock_session();
329 let result = session.dispatch("new");
330 assert_eq!(result, r#"new "Untitled" created"#);
331 }
332
333 #[test]
334 fn test_session_new_clears_file_path() {
335 let mut session = create_mock_session();
336 session.dispatch("new Test");
337 assert_eq!(session.file_path, "");
338 }
339
340 #[test]
341 fn test_session_open() {
342 let mut session = create_mock_session();
343 let result = session.dispatch("open ./test.mid");
344 assert_eq!(result, r#"opened "./test.mid""#);
345 assert!(session.model.is_some());
346 assert_eq!(session.file_path, "./test.mid");
347 }
348
349 #[test]
350 fn test_session_open_requires_path() {
351 let mut session = create_mock_session();
352 let result = session.dispatch("open");
353 assert_eq!(result, "open requires a file path");
354 }
355
356 #[test]
357 fn test_session_open_error() {
358 let mut session = Session::new(MockHooks {
359 open_error: Some("file not found".to_string()),
360 save_error: None,
361 });
362 let result = session.dispatch("open ./missing.mid");
363 assert_eq!(result, "error: file not found");
364 }
365
366 #[test]
367 fn test_session_save() {
368 let mut session = create_mock_session();
369 session.dispatch("open ./test.mid");
370 let result = session.dispatch("save");
371 assert_eq!(result, r#"saved "./test.mid""#);
372 }
373
374 #[test]
375 fn test_session_save_with_as() {
376 let mut session = create_mock_session();
377 session.dispatch("new Test");
378 let result = session.dispatch("save as:./output.mid");
379 assert_eq!(result, r#"saved "./output.mid""#);
380 assert_eq!(session.file_path, "./output.mid");
381 }
382
383 #[test]
384 fn test_session_save_no_model() {
385 let mut session = create_mock_session();
386 let result = session.dispatch("save");
387 assert_eq!(result, "error: no model to save");
388 }
389
390 #[test]
391 fn test_session_save_no_path() {
392 let mut session = create_mock_session();
393 session.dispatch("new Test");
394 let result = session.dispatch("save");
395 assert_eq!(result, "error: no file path. Use save as:./file");
396 }
397
398 #[test]
399 fn test_session_checkpoint() {
400 let mut session = create_mock_session();
401 let result = session.dispatch("checkpoint v1");
402 assert_eq!(result, r#"checkpoint "v1" created"#);
403 assert!(session.log.cursor() > 0);
404 }
405
406 #[test]
407 fn test_session_checkpoint_requires_name() {
408 let mut session = create_mock_session();
409 let result = session.dispatch("checkpoint");
410 assert_eq!(result, "checkpoint requires a name");
411 }
412
413 #[test]
414 fn test_session_undo() {
415 let mut session = create_mock_session();
416 session.dispatch("new Test");
417 session.log.append(MockEvent("event1".to_string()));
418 session.log.append(MockEvent("event2".to_string()));
419 let result = session.dispatch("undo");
420 assert_eq!(result, "undone 1 event");
421 }
422
423 #[test]
424 fn test_session_undo_no_model() {
425 let mut session = create_mock_session();
426 let result = session.dispatch("undo");
427 assert_eq!(result, "nothing to undo");
428 }
429
430 #[test]
431 fn test_session_undo_to_checkpoint() {
432 let mut session = create_mock_session();
433 session.dispatch("new Test");
434 session.log.append(MockEvent("event1".to_string()));
435 session.dispatch("checkpoint v1");
436 session.log.append(MockEvent("event2".to_string()));
437 session.log.append(MockEvent("event3".to_string()));
438 let result = session.dispatch("undo to:v1");
439 assert_eq!(result, r#"undone 2 events to checkpoint "v1""#);
440 }
441
442 #[test]
443 fn test_session_undo_calls_rebuild() {
444 let mut session = create_mock_session();
445 session.dispatch("new Test");
446 session.log.append(MockEvent("event1".to_string()));
447 let result = session.dispatch("undo");
448 assert_eq!(result, "undone 1 event");
449 }
451
452 #[test]
453 fn test_session_redo() {
454 let mut session = create_mock_session();
455 session.dispatch("new Test");
456 session.log.append(MockEvent("event1".to_string()));
457 session.log.undo(1);
458 let result = session.dispatch("redo");
459 assert_eq!(result, "redone 1 event");
460 }
461
462 #[test]
463 fn test_session_redo_no_model() {
464 let mut session = create_mock_session();
465 let result = session.dispatch("redo");
466 assert_eq!(result, "nothing to redo");
467 }
468
469 #[test]
470 fn test_session_redo_calls_rebuild() {
471 let mut session = create_mock_session();
472 session.dispatch("new Test");
473 session.log.append(MockEvent("event1".to_string()));
474 session.log.undo(1);
475 let result = session.dispatch("redo");
476 assert_eq!(result, "redone 1 event");
477 }
479
480 #[test]
481 fn test_session_unknown_command() {
482 let mut session = create_mock_session();
483 let result = session.dispatch("explode");
484 assert_eq!(result, r#"unknown session action "explode""#);
485 }
486
487 #[test]
488 fn test_session_empty_action() {
489 let mut session = create_mock_session();
490 let result = session.dispatch("");
491 assert_eq!(result, "empty action");
492 }
493
494 #[test]
497 fn test_session_status_placeholder() {
498 let mut session = create_mock_session();
499 let result = session.dispatch("status");
500 assert_eq!(result, "status: placeholder");
501 }
502
503 #[test]
504 fn test_session_close_placeholder() {
505 let mut session = create_mock_session();
506 let result = session.dispatch("close");
507 assert_eq!(result, "close: placeholder");
508 }
509
510 #[test]
511 fn test_session_new_resets_log() {
512 let mut session = create_mock_session();
513 session.dispatch("new Test");
514 session.log.append(MockEvent("e1".to_string()));
515 session.log.append(MockEvent("e2".to_string()));
516 assert_eq!(session.log.cursor(), 2);
517 session.dispatch("new Other");
518 assert_eq!(session.log.cursor(), 0);
519 }
520
521 #[test]
522 fn test_session_open_resets_log() {
523 let mut session = create_mock_session();
524 session.dispatch("new Test");
525 session.log.append(MockEvent("e1".to_string()));
526 assert_eq!(session.log.cursor(), 1);
527 session.dispatch("open ./file.mid");
528 assert_eq!(session.log.cursor(), 0);
529 }
530}