• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

MitMaro / git-interactive-rebase-tool / 24379379782

14 Apr 2026 03:27AM UTC coverage: 93.969% (-3.6%) from 97.589%
24379379782

Pull #988

github

web-flow
Merge 0e7f07116 into 4d16b296a
Pull Request #988: Bump rand from 0.9.0 to 0.9.4

5531 of 5886 relevant lines covered (93.97%)

6.26 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.03
/src/application.rs
1
mod app_data;
2

3
use std::sync::Arc;
4

5
use anyhow::Result;
6
use parking_lot::Mutex;
7

8
pub(crate) use crate::application::app_data::AppData;
9
use crate::{
10
        Args,
11
        Exit,
12
        config::{Config, ConfigLoader, DiffIgnoreWhitespaceSetting},
13
        diff::{self, CommitDiffLoader, CommitDiffLoaderOptions},
14
        display::Display,
15
        git::open_repository_from_env,
16
        help::build_help,
17
        input::{Event, EventHandler, EventReaderFn, KeyBindings, StandardEvent},
18
        module::{self, ExitStatus, ModuleHandler, State},
19
        process::{self, Process},
20
        runtime::{Runtime, ThreadStatuses, Threadable},
21
        search,
22
        todo_file::{TodoFile, TodoFileOptions},
23
        view::View,
24
};
25

26
pub(crate) struct Application<ModuleProvider>
27
where ModuleProvider: module::ModuleProvider + Send + 'static
28
{
29
        process: Process<ModuleProvider>,
30
        threads: Option<Vec<Box<dyn Threadable>>>,
31
        thread_statuses: ThreadStatuses,
32
}
33

34
impl<ModuleProvider> Application<ModuleProvider>
35
where ModuleProvider: module::ModuleProvider + Send + 'static
36
{
37
        pub(crate) fn new<EventProvider, Tui>(args: &Args, event_provider: EventProvider, tui: Tui) -> Result<Self, Exit>
11✔
38
        where
39
                EventProvider: EventReaderFn,
40
                Tui: crate::display::Tui + Send + 'static,
41
        {
42
                let filepath = Self::filepath_from_args(args)?;
23✔
43
                let repository = Self::open_repository()?;
21✔
44
                let config_loader = ConfigLoader::from(repository);
18✔
45
                let config = Self::load_config(&config_loader)?;
19✔
46
                let todo_file = Arc::new(Mutex::new(Self::load_todo_file(filepath.as_str(), &config)?));
16✔
47

48
                let display = Display::new(tui, &config.theme);
8✔
49
                let initial_display_size = display.get_window_size();
8✔
50
                let view = View::new(
51
                        display,
4✔
52
                        config.theme.character_vertical_spacing.as_str(),
4✔
53
                        config
4✔
54
                                .key_bindings
×
55
                                .help
×
56
                                .first()
4✔
57
                                .map_or_else(|| String::from("?"), String::from)
4✔
58
                                .as_str(),
4✔
59
                );
60

61
                let thread_statuses = ThreadStatuses::new();
4✔
62
                let mut threads: Vec<Box<dyn Threadable>> = vec![];
4✔
63

64
                let input_threads = crate::input::Thread::new(event_provider);
8✔
65
                let input_state = input_threads.state();
4✔
66
                threads.push(Box::new(input_threads));
8✔
67

68
                let view_threads = crate::view::Thread::new(view);
4✔
69
                let view_state = view_threads.state();
4✔
70
                threads.push(Box::new(view_threads));
8✔
71

72
                let search_update_handler = Self::create_search_update_handler(input_state.clone());
4✔
73
                let search_threads = search::Thread::new(search_update_handler);
4✔
74
                let search_state = search_threads.state();
4✔
75
                threads.push(Box::new(search_threads));
8✔
76

77
                let commit_diff_loader_options = CommitDiffLoaderOptions::new()
4✔
78
                        .context_lines(config.git.diff_context)
4✔
79
                        .copies(config.git.diff_copies)
4✔
80
                        .ignore_whitespace(config.diff_ignore_whitespace == DiffIgnoreWhitespaceSetting::All)
4✔
81
                        .ignore_whitespace_change(config.diff_ignore_whitespace == DiffIgnoreWhitespaceSetting::Change)
4✔
82
                        .ignore_blank_lines(config.diff_ignore_blank_lines)
4✔
83
                        .interhunk_context(config.git.diff_interhunk_lines)
4✔
84
                        .renames(config.git.diff_renames, config.git.diff_rename_limit);
4✔
85
                let commit_diff_loader = CommitDiffLoader::new(config_loader.eject_repository(), commit_diff_loader_options);
4✔
86

87
                let diff_update_handler = Self::create_diff_update_handler(input_state.clone());
8✔
88
                let diff_thread = diff::thread::Thread::new(commit_diff_loader, diff_update_handler);
4✔
89
                let diff_state = diff_thread.state();
4✔
90
                threads.push(Box::new(diff_thread));
8✔
91

92
                let keybindings = KeyBindings::new(&config.key_bindings);
4✔
93

94
                let app_data = AppData::new(
95
                        config,
4✔
96
                        State::WindowSizeError,
×
97
                        Arc::clone(&todo_file),
8✔
98
                        diff_state.clone(),
8✔
99
                        view_state.clone(),
8✔
100
                        input_state.clone(),
8✔
101
                        search_state.clone(),
4✔
102
                );
103

104
                let module_handler = ModuleHandler::new(EventHandler::new(keybindings), ModuleProvider::new(&app_data));
8✔
105

106
                let process = Process::new(&app_data, initial_display_size, module_handler, thread_statuses.clone());
4✔
107
                let process_threads = process::Thread::new(process.clone());
8✔
108
                threads.push(Box::new(process_threads));
4✔
109

110
                Ok(Self {
4✔
111
                        process,
4✔
112
                        threads: Some(threads),
4✔
113
                        thread_statuses,
4✔
114
                })
115
        }
116

117
        pub(crate) fn run_until_finished(&mut self) -> Result<(), Exit> {
1✔
118
                let Some(mut threads) = self.threads.take()
1✔
119
                else {
×
120
                        return Err(Exit::new(
1✔
121
                                ExitStatus::StateError,
×
122
                                "Attempt made to run application a second time",
×
123
                        ));
124
                };
125

126
                let runtime = Runtime::new(self.thread_statuses.clone());
2✔
127

128
                for thread in &mut threads {
2✔
129
                        runtime.register(thread.as_mut());
2✔
130
                }
131

132
                runtime.join().map_err(|err| {
4✔
133
                        Exit::new(
1✔
134
                                ExitStatus::StateError,
×
135
                                format!("Failed to join runtime: {err}").as_str(),
2✔
136
                        )
137
                })?;
138

139
                let exit_status = self.process.exit_status();
1✔
140
                if exit_status != ExitStatus::Good {
1✔
141
                        return Err(Exit::from(exit_status));
2✔
142
                }
143

144
                Ok(())
1✔
145
        }
146

147
        fn filepath_from_args(args: &Args) -> Result<String, Exit> {
2✔
148
                args.todo_file_path().map(String::from).ok_or_else(|| {
3✔
149
                        Exit::new(
1✔
150
                                ExitStatus::StateError,
×
151
                                build_help(Some(String::from("A todo file path must be provided."))).as_str(),
1✔
152
                        )
153
                })
154
        }
155

156
        fn open_repository() -> Result<git2::Repository, Exit> {
2✔
157
                open_repository_from_env().map_err(|err| {
3✔
158
                        Exit::new(
1✔
159
                                ExitStatus::StateError,
×
160
                                format!("Unable to load Git repository: {err}").as_str(),
2✔
161
                        )
162
                })
163
        }
164

165
        fn load_config(config_loader: &ConfigLoader) -> Result<Config, Exit> {
2✔
166
                Config::try_from(config_loader).map_err(|err| Exit::new(ExitStatus::ConfigError, format!("{err:#}").as_str()))
4✔
167
        }
168

169
        fn todo_file_options(config: &Config) -> TodoFileOptions {
2✔
170
                let mut todo_file_options = TodoFileOptions::new(config.undo_limit, config.git.comment_char.as_str());
2✔
171
                if let Some(command) = config.post_modified_line_exec_command.as_deref() {
4✔
172
                        todo_file_options.line_changed_command(command);
2✔
173
                }
174
                todo_file_options
2✔
175
        }
176

177
        fn load_todo_file(filepath: &str, config: &Config) -> Result<TodoFile, Exit> {
2✔
178
                let mut todo_file = TodoFile::new(filepath, Self::todo_file_options(config));
2✔
179
                todo_file
6✔
180
                        .load_file()
181
                        .map_err(|err| Exit::new(ExitStatus::FileReadError, err.to_string().as_str()))?;
8✔
182

183
                if todo_file.is_noop() {
2✔
184
                        return Err(Exit::new(
2✔
185
                                ExitStatus::Good,
×
186
                                "A noop rebase was provided, skipping editing",
×
187
                        ));
188
                }
189

190
                if todo_file.is_empty() {
4✔
191
                        return Err(Exit::new(
4✔
192
                                ExitStatus::Good,
×
193
                                "An empty rebase was provided, nothing to edit",
×
194
                        ));
195
                }
196

197
                Ok(todo_file)
1✔
198
        }
199

200
        fn create_search_update_handler(input_state: crate::input::State) -> impl Fn() + Send + Sync {
2✔
201
                move || input_state.push_event(Event::Standard(StandardEvent::SearchUpdate))
4✔
202
        }
203

204
        fn create_diff_update_handler(input_state: crate::input::State) -> impl Fn() + Send + Sync {
1✔
205
                move || input_state.push_event(Event::Standard(StandardEvent::DiffUpdate))
1✔
206
        }
207
}
208

209
#[cfg(all(unix, test))]
210
mod tests {
211
        use std::ffi::OsString;
212

213
        use claims::assert_ok;
214

215
        use super::*;
216
        use crate::{
217
                display::Size,
218
                input::{KeyCode, KeyEvent, KeyModifiers},
219
                module::Modules,
220
                runtime::{Installer, RuntimeError},
221
                test_helpers::{
222
                        DefaultTestModule,
223
                        TestModuleProvider,
224
                        create_config,
225
                        create_event_reader,
226
                        mocks,
227
                        with_git_directory,
228
                },
229
        };
230

231
        fn args(args: &[&str]) -> Args {
1✔
232
                Args::try_from(args.iter().map(OsString::from).collect::<Vec<OsString>>()).unwrap()
1✔
233
        }
234

235
        fn create_mocked_crossterm() -> mocks::CrossTerm {
1✔
236
                let mut crossterm = mocks::CrossTerm::new();
1✔
237
                crossterm.set_size(Size::new(300, 120));
2✔
238
                crossterm
1✔
239
        }
240

241
        macro_rules! application_error {
242
                ($app:expr) => {
243
                        if let Err(e) = $app {
6✔
244
                                e
6✔
245
                        }
246
                        else {
247
                                panic!("Application is not in an error state");
×
248
                        }
249
                };
250
        }
251

252
        #[test]
253
        #[serial_test::serial]
254
        fn load_filepath_from_args_failure() {
255
                let event_provider = create_event_reader(|| Ok(None));
256
                let application: Result<Application<TestModuleProvider<DefaultTestModule>>, Exit> =
257
                        Application::new(&args(&[]), event_provider, create_mocked_crossterm());
258
                let exit = application_error!(application);
259
                assert_eq!(exit.get_status(), &ExitStatus::StateError);
260
                assert!(
261
                        exit.get_message()
262
                                .unwrap()
263
                                .contains("A todo file path must be provided")
264
                );
265
        }
266

267
        #[test]
268
        fn load_repository_failure() {
269
                with_git_directory("fixtures/not-a-repository", |_| {
270
                        let event_provider = create_event_reader(|| Ok(None));
271
                        let application: Result<Application<TestModuleProvider<DefaultTestModule>>, Exit> =
272
                                Application::new(&args(&["todofile"]), event_provider, create_mocked_crossterm());
273
                        let exit = application_error!(application);
274
                        assert_eq!(exit.get_status(), &ExitStatus::StateError);
275
                        assert!(exit.get_message().unwrap().contains("Unable to load Git repository: "));
276
                });
277
        }
278

279
        #[test]
280
        fn load_config_failure() {
281
                with_git_directory("fixtures/invalid-config", |_| {
282
                        let event_provider = create_event_reader(|| Ok(None));
283
                        let application: Result<Application<TestModuleProvider<DefaultTestModule>>, Exit> =
284
                                Application::new(&args(&["rebase-todo"]), event_provider, create_mocked_crossterm());
285
                        let exit = application_error!(application);
286
                        assert_eq!(exit.get_status(), &ExitStatus::ConfigError);
287
                });
288
        }
289

290
        #[test]
291
        fn todo_file_options_without_command() {
292
                let mut config = create_config();
293
                config.undo_limit = 10;
294
                config.git.comment_char = String::from("#");
295
                config.post_modified_line_exec_command = None;
296

297
                let expected = TodoFileOptions::new(10, "#");
298
                assert_eq!(
299
                        Application::<TestModuleProvider<DefaultTestModule>>::todo_file_options(&config),
300
                        expected
301
                );
302
        }
303

304
        #[test]
305
        fn todo_file_options_with_command() {
306
                let mut config = create_config();
307
                config.undo_limit = 10;
308
                config.git.comment_char = String::from("#");
309
                config.post_modified_line_exec_command = Some(String::from("command"));
310

311
                let mut expected = TodoFileOptions::new(10, "#");
312
                expected.line_changed_command("command");
313

314
                assert_eq!(
315
                        Application::<TestModuleProvider<DefaultTestModule>>::todo_file_options(&config),
316
                        expected
317
                );
318
        }
319

320
        #[test]
321
        fn load_todo_file_load_error() {
322
                with_git_directory("fixtures/simple", |_| {
323
                        let event_provider = create_event_reader(|| Ok(None));
324
                        let application: Result<Application<TestModuleProvider<DefaultTestModule>>, Exit> =
325
                                Application::new(&args(&["does-not-exist"]), event_provider, create_mocked_crossterm());
326
                        let exit = application_error!(application);
327
                        assert_eq!(exit.get_status(), &ExitStatus::FileReadError);
328
                });
329
        }
330

331
        #[test]
332
        fn load_todo_file_noop() {
333
                with_git_directory("fixtures/simple", |git_dir| {
334
                        let rebase_todo = format!("{git_dir}/rebase-todo-noop");
335
                        let event_provider = create_event_reader(|| Ok(None));
336
                        let application: Result<Application<TestModuleProvider<DefaultTestModule>>, Exit> = Application::new(
337
                                &args(&[rebase_todo.as_str()]),
338
                                event_provider,
339
                                create_mocked_crossterm(),
340
                        );
341
                        let exit = application_error!(application);
342
                        assert_eq!(exit.get_status(), &ExitStatus::Good);
343
                });
344
        }
345

346
        #[test]
347
        fn load_todo_file_empty() {
348
                with_git_directory("fixtures/simple", |git_dir| {
349
                        let rebase_todo = format!("{git_dir}/rebase-todo-empty");
350
                        let event_provider = create_event_reader(|| Ok(None));
351
                        let application: Result<Application<TestModuleProvider<DefaultTestModule>>, Exit> = Application::new(
352
                                &args(&[rebase_todo.as_str()]),
353
                                event_provider,
354
                                create_mocked_crossterm(),
355
                        );
356
                        let exit = application_error!(application);
357
                        assert_eq!(exit.get_status(), &ExitStatus::Good);
358
                        assert!(
359
                                exit.get_message()
360
                                        .unwrap()
361
                                        .contains("An empty rebase was provided, nothing to edit")
362
                        );
363
                });
364
        }
365

366
        #[test]
367
        #[serial_test::serial]
368
        fn search_update_handler_handles_update() {
369
                let event_provider = create_event_reader(|| Ok(None));
370
                let input_threads = crate::input::Thread::new(event_provider);
371
                let input_state = input_threads.state();
372
                let update_handler =
373
                        Application::<TestModuleProvider<DefaultTestModule>>::create_search_update_handler(input_state.clone());
374
                update_handler();
375

376
                assert_eq!(input_state.read_event(), Event::Standard(StandardEvent::SearchUpdate));
377
        }
378

379
        #[test]
380
        fn run_until_finished_success() {
381
                with_git_directory("fixtures/simple", |git_dir| {
382
                        let rebase_todo = format!("{git_dir}/rebase-todo");
383
                        let event_provider = create_event_reader(|| Ok(Some(Event::Key(KeyEvent::from(KeyCode::Char('W'))))));
384
                        let mut application: Application<Modules> = Application::new(
385
                                &args(&[rebase_todo.as_str()]),
386
                                event_provider,
387
                                create_mocked_crossterm(),
388
                        )
389
                        .unwrap();
390
                        assert_ok!(application.run_until_finished());
391
                });
392
        }
393

394
        #[test]
395
        fn run_join_error() {
396
                struct FailingThread;
397
                impl Threadable for FailingThread {
398
                        fn install(&self, installer: &Installer) {
399
                                installer.spawn("THREAD", |notifier| {
400
                                        move || {
401
                                                notifier.error(RuntimeError::ThreadSpawnError(String::from("Error")));
402
                                        }
403
                                });
404
                        }
405
                }
406

407
                with_git_directory("fixtures/simple", |git_dir| {
408
                        let rebase_todo = format!("{git_dir}/rebase-todo");
409
                        let event_provider = create_event_reader(|| Ok(Some(Event::Key(KeyEvent::from(KeyCode::Char('W'))))));
410
                        let mut application: Application<Modules> = Application::new(
411
                                &args(&[rebase_todo.as_str()]),
412
                                event_provider,
413
                                create_mocked_crossterm(),
414
                        )
415
                        .unwrap();
416

417
                        application.threads = Some(vec![Box::new(FailingThread {})]);
418

419
                        let exit = application.run_until_finished().unwrap_err();
420
                        assert_eq!(exit.get_status(), &ExitStatus::StateError);
421
                        assert!(exit.get_message().unwrap().starts_with("Failed to join runtime:"));
422
                });
423
        }
424

425
        #[test]
426
        fn run_until_finished_kill() {
427
                with_git_directory("fixtures/simple", |git_dir| {
428
                        let rebase_todo = format!("{git_dir}/rebase-todo");
429
                        let event_provider = create_event_reader(|| {
430
                                Ok(Some(Event::Key(KeyEvent::new(
431
                                        KeyCode::Char('c'),
432
                                        KeyModifiers::CONTROL,
433
                                ))))
434
                        });
435
                        let mut application: Application<Modules> = Application::new(
436
                                &args(&[rebase_todo.as_str()]),
437
                                event_provider,
438
                                create_mocked_crossterm(),
439
                        )
440
                        .unwrap();
441
                        let exit = application.run_until_finished().unwrap_err();
442
                        assert_eq!(exit.get_status(), &ExitStatus::Kill);
443
                });
444
        }
445

446
        #[test]
447
        fn run_error_on_second_attempt() {
448
                with_git_directory("fixtures/simple", |git_dir| {
449
                        let rebase_todo = format!("{git_dir}/rebase-todo");
450
                        let event_provider = create_event_reader(|| Ok(Some(Event::Key(KeyEvent::from(KeyCode::Char('W'))))));
451
                        let mut application: Application<Modules> = Application::new(
452
                                &args(&[rebase_todo.as_str()]),
453
                                event_provider,
454
                                create_mocked_crossterm(),
455
                        )
456
                        .unwrap();
457
                        assert_ok!(application.run_until_finished());
458
                        let exit = application.run_until_finished().unwrap_err();
459
                        assert_eq!(exit.get_status(), &ExitStatus::StateError);
460
                        assert_eq!(
461
                                exit.get_message().unwrap(),
462
                                "Attempt made to run application a second time"
463
                        );
464
                });
465
        }
466
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc