Skip to main content

fcp_core/
session.rs

1use std::collections::HashMap;
2
3use crate::event_log::EventLog;
4use crate::tokenizer::{is_key_value, parse_key_value, tokenize};
5
6/// Lifecycle hooks that a domain must implement.
7pub trait SessionHooks {
8    /// The model type for this domain.
9    type Model;
10    /// The event type for undo/redo.
11    type Event: Clone;
12
13    /// Creates a new empty model with the given params.
14    fn on_new(&self, params: &HashMap<String, String>) -> Result<Self::Model, String>;
15    /// Opens a model from a file path.
16    fn on_open(&self, path: &str) -> Result<Self::Model, String>;
17    /// Saves a model to a file path.
18    fn on_save(&self, model: &Self::Model, path: &str) -> Result<(), String>;
19    /// Rebuilds derived indices after undo/redo.
20    fn on_rebuild_indices(&self, model: &mut Self::Model);
21    /// Returns a compact digest string for drift detection.
22    fn get_digest(&self, model: &Self::Model) -> String;
23    /// Reverses a single event on the model (for undo).
24    fn reverse(&self, event: &Self::Event, model: &mut Self::Model);
25    /// Replays a single event on the model (for redo).
26    fn replay(&self, event: &Self::Event, model: &mut Self::Model);
27}
28
29/// Session routes session-level actions (new, open, save, checkpoint, undo, redo)
30/// to the appropriate handler.
31pub 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    /// Creates a new Session with the given hooks.
40    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    /// Parses and executes a session command string.
50    /// Commands: new "Title" [params...], open PATH, save, save as:PATH,
51    /// checkpoint NAME, undo, undo to:NAME, redo, status, close
52    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(&params) {
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        // undo to:NAME
162        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        // The model title should be set from the first positional
322        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        // If it didn't panic, rebuild was called
450    }
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        // If it didn't panic, rebuild was called
478    }
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    // Workspace-specific placeholder tests
495
496    #[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}