1use std::path::{Path, PathBuf};
12
13use eframe::egui::{self, Key, KeyboardShortcut, Modifiers, Ui};
14use odox_core::Document;
15
16use crate::edit::{Caret, Editing};
17use crate::find::Replaced;
18use crate::find_bar::{FindBar, Replace, Step};
19use crate::i18n::{fill, t};
20use crate::settings::Settings;
21
22pub struct Product {
27 pub id: &'static str,
31 pub extension: &'static str,
33 pub format: &'static str,
39 pub icon: &'static [u8],
48}
49
50pub trait View {
52 fn open(&mut self, ctx: &egui::Context, bytes: &[u8], path: &Path) -> Result<(), String>;
63
64 fn close(&mut self);
66
67 fn is_open(&self) -> bool;
69
70 fn title(&self) -> Option<String>;
72
73 fn document_mut(&mut self) -> Option<&mut Document>;
75
76 fn reindex(&mut self) {}
79
80 fn caret(&self) -> Option<Caret> {
83 None
84 }
85
86 fn restore_caret(&mut self, _caret: Caret) {}
89
90 fn menus(&mut self, _ui: &mut Ui, _editing: &Editing) {}
94
95 fn presenting(&self) -> bool {
99 false
100 }
101
102 fn find(&mut self, _query: &str) -> usize {
107 0
108 }
109
110 fn can_replace(&self, _editing: &Editing) -> bool {
113 false
114 }
115
116 fn replace(&mut self, _with: &str, _all: bool, _editing: &mut Editing) -> Replaced {
119 Replaced::default()
120 }
121
122 fn show_match(&mut self, _index: usize) {}
124
125 fn central(&mut self, ui: &mut Ui, zoom: f32, editing: &mut Editing);
129
130 fn side(&mut self, _ui: &mut Ui) -> bool {
133 false
134 }
135
136 fn view_menu(&mut self, _ui: &mut Ui) {}
138}
139
140pub struct Shell<V: View> {
142 view: V,
143 product: Product,
144 path: Option<PathBuf>,
145 error: Option<String>,
146 settling: bool,
150 zoom: f32,
151 show_side: bool,
152 editing: Editing,
153 settings: Settings,
154 find: FindBar,
155 screen: Screen,
157 pending: Option<Pending>,
160 closing: bool,
163}
164
165#[derive(Clone, Copy, PartialEq, Eq)]
167enum Screen {
168 Window,
169 Full,
170}
171
172#[derive(Clone, Copy)]
175enum Pending {
176 AskForAFile,
177 Reload,
178 Close,
179 Quit,
180}
181
182const ZOOM_STEP: f32 = 1.1;
184const ZOOM_MIN: f32 = 0.4;
185const ZOOM_MAX: f32 = 4.0;
186
187impl<V: View> Shell<V> {
188 pub fn new(product: Product, view: V) -> Self {
190 Self {
191 view,
192 product,
193 path: None,
194 error: None,
195 settling: false,
196 zoom: 1.0,
197 show_side: true,
198 editing: Editing::default(),
199 settings: Settings::load(),
200 find: FindBar::default(),
201 screen: Screen::Window,
202 pending: None,
203 closing: false,
204 }
205 }
206
207 pub fn open(&mut self, ctx: &egui::Context, path: &Path) {
213 match std::fs::read(path) {
214 Ok(bytes) => match self.view.open(ctx, &bytes, path) {
215 Ok(()) => {
216 self.path = Some(path.to_path_buf());
217 self.error = None;
218 self.settling = true;
219 self.editing.reset();
220 self.editing.on = self.settings.open_in_edit_mode;
221 }
222 Err(message) => {
223 self.view.close();
224 self.path = None;
225 self.error = Some(message);
226 self.editing.reset();
227 }
228 },
229 Err(e) => {
230 self.error = Some(fill(
231 t("{file} could not be read: {reason}"),
232 &[("file", &name_of(path)), ("reason", &e.to_string())],
233 ));
234 }
235 }
236 }
237
238 fn request(&mut self, ctx: &egui::Context, action: Pending) {
244 if self.editing.modified() {
245 self.pending = Some(action);
246 } else {
247 self.perform(ctx, action);
248 }
249 }
250
251 fn perform(&mut self, ctx: &egui::Context, action: Pending) {
252 match action {
253 Pending::AskForAFile => self.ask_for_a_file(ctx),
254 Pending::Reload => {
255 if let Some(path) = self.path.clone() {
256 self.open(ctx, &path);
257 }
258 }
259 Pending::Close => self.close(),
260 Pending::Quit => {
261 self.closing = true;
262 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
263 }
264 }
265 }
266
267 fn ask_for_a_file(&mut self, ctx: &egui::Context) {
268 let file = rfd::FileDialog::new()
269 .add_filter(t(self.product.format), &[self.product.extension])
270 .pick_file();
271 if let Some(path) = file {
272 self.open(ctx, &path);
273 }
274 }
275
276 fn close(&mut self) {
277 self.view.close();
278 self.path = None;
279 self.error = None;
280 self.editing.reset();
281 }
282
283 fn save(&mut self) -> bool {
288 match self.path.clone() {
289 Some(path) => self.write_to(&path),
290 None => self.save_as(),
291 }
292 }
293
294 fn save_as(&mut self) -> bool {
296 let mut dialog =
297 rfd::FileDialog::new().add_filter(t(self.product.format), &[self.product.extension]);
298 if let Some(path) = &self.path {
299 dialog = dialog.set_file_name(name_of(path));
300 if let Some(directory) = path.parent() {
301 dialog = dialog.set_directory(directory);
302 }
303 }
304 let Some(mut path) = dialog.save_file() else {
305 return false;
306 };
307 if path.extension().is_none() {
311 path.set_extension(self.product.extension);
312 }
313 if self.write_to(&path) {
314 self.path = Some(path);
315 true
316 } else {
317 false
318 }
319 }
320
321 fn write_to(&mut self, path: &Path) -> bool {
324 let Some(document) = self.view.document_mut() else {
325 return false;
326 };
327 let written = document
328 .write_verified()
329 .map_err(|e| e.to_string())
330 .and_then(|bytes| replace_file(path, &bytes).map_err(|e| e.to_string()));
331 match written {
332 Ok(()) => {
333 self.editing.mark_saved();
334 self.error = None;
335 true
336 }
337 Err(reason) => {
338 self.error = Some(fill(
339 t("{file} could not be saved: {reason}"),
340 &[("file", &name_of(path)), ("reason", &reason)],
341 ));
342 false
343 }
344 }
345 }
346
347 fn undo(&mut self) {
348 let caret = self.view.caret();
349 if let Some(document) = self.view.document_mut()
350 && let Some((previous, caret)) = self.editing.undo(&document.content, caret)
351 {
352 document.content = previous;
353 self.after_history(caret);
354 }
355 }
356
357 fn redo(&mut self) {
358 let caret = self.view.caret();
359 if let Some(document) = self.view.document_mut()
360 && let Some((next, caret)) = self.editing.redo(&document.content, caret)
361 {
362 document.content = next;
363 self.after_history(caret);
364 }
365 }
366
367 fn after_history(&mut self, caret: Option<Caret>) {
370 self.view.reindex();
371 if let Some(caret) = caret {
372 self.view.restore_caret(caret);
373 }
374 }
375
376 fn window_title(&self) -> String {
379 let document = self
380 .view
381 .title()
382 .filter(|title| !title.trim().is_empty())
383 .or_else(|| self.path.as_deref().map(name_of));
384 let mark = if self.editing.modified() { "● " } else { "" };
385 match document {
386 Some(name) => format!("{mark}{name} — {}", self.product.id),
387 None => self.product.id.to_owned(),
388 }
389 }
390
391 fn ask_about_changes(&mut self, ctx: &egui::Context) {
393 if self.pending.is_none() {
394 return;
395 }
396 let file = self.path.as_deref().map(name_of).unwrap_or_default();
397 let mut answer = None;
399 egui::Modal::new(egui::Id::new("unsaved")).show(ctx, |ui| {
400 ui.heading(fill(t("Save changes to {file}?"), &[("file", &file)]));
401 ui.label(t("The document has changes that are not saved."));
402 ui.add_space(8.0);
403 ui.horizontal(|ui| {
404 if ui.button(t("Save")).clicked() || ui.input(|i| i.key_pressed(Key::Enter)) {
405 answer = Some(Some(true));
406 }
407 if ui.button(t("Discard")).clicked() {
408 answer = Some(Some(false));
409 }
410 if ui.button(t("Cancel")).clicked() || ui.input(|i| i.key_pressed(Key::Escape)) {
411 answer = Some(None);
412 }
413 });
414 });
415 let Some(answer) = answer else {
416 return;
417 };
418 let Some(action) = self.pending.take() else {
419 return;
420 };
421 match answer {
422 Some(true) => {
423 if self.save() {
424 self.perform(ctx, action);
425 }
426 }
427 Some(false) => self.perform(ctx, action),
428 None => {}
429 }
430 }
431}
432
433impl<V: View> eframe::App for Shell<V> {
434 fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) {
435 let ctx = ui.ctx().clone();
436
437 if ctx.input(|i| i.viewport().close_requested()) && !self.closing && self.editing.modified()
440 {
441 ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
442 self.pending = Some(Pending::Quit);
443 }
444
445 let presenting = self.view.presenting();
451 let screen = if presenting {
452 Screen::Full
453 } else {
454 Screen::Window
455 };
456 if screen != self.screen {
457 self.screen = screen;
458 ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(presenting));
459 }
460 self.editing.asking = self.pending.is_some();
461 let field_focused = ctx
462 .memory(egui::Memory::focused)
463 .is_some_and(|id| id != crate::flow_model::page_editor_id());
464 if self.pending.is_none() && !presenting {
465 self.find_keys(&ctx);
466 }
467 if self.pending.is_none() && !field_focused && !presenting {
468 self.keys(&ctx);
469 }
470
471 #[cfg(target_os = "macos")]
475 if let Some(path) = crate::opened_document::taken() {
476 self.open(&ctx, &path);
477 }
478
479 ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.window_title()));
480
481 if !presenting {
482 self.chrome(ui, &ctx);
483 }
484
485 let settling = self.settling;
504 if settling {
505 self.settling = false;
506 ctx.request_repaint();
507 }
508
509 let central = if presenting {
510 egui::CentralPanel::default().frame(egui::Frame::NONE.fill(egui::Color32::BLACK))
511 } else {
512 egui::CentralPanel::default_margins()
513 };
514 central.show(ui, |ui| {
515 if settling {
516 } else if self.view.is_open() {
520 self.view.central(ui, self.zoom, &mut self.editing);
521 } else {
522 self.nothing_open(ui);
523 }
524 });
525
526 self.ask_about_changes(&ctx);
527 }
528}
529
530impl<V: View> Shell<V> {
531 fn chrome(&mut self, ui: &mut Ui, ctx: &egui::Context) {
534 egui::Panel::top("menu").show(ui, |ui| self.menu_bar(ui, ctx));
535 self.search(ui);
536
537 if let Some(message) = self.error.clone() {
538 egui::Panel::bottom("error").show(ui, |ui| {
539 ui.horizontal_wrapped(|ui| {
540 ui.colored_label(ui.visuals().error_fg_color, "\u{26a0}");
541 ui.label(message);
542 if ui.button(t("Dismiss")).clicked() {
543 self.error = None;
544 }
545 });
546 });
547 }
548
549 if self.show_side && self.view.is_open() {
550 let mut drew = false;
554 egui::Panel::left("side")
555 .resizable(true)
556 .default_size(220.0)
557 .min_size(120.0)
558 .show(ui, |ui| {
559 egui::ScrollArea::both().show(ui, |ui| {
560 drew = self.view.side(ui);
561 });
562 });
563 if !drew {
564 self.show_side = false;
565 }
566 }
567 }
568
569 fn menu_bar(&mut self, ui: &mut Ui, ctx: &egui::Context) {
574 egui::MenuBar::new().ui(ui, |ui| {
575 ui.menu_button(t("File"), |ui| {
576 if ui.button(t("Open…")).clicked() {
577 ui.close();
578 self.request(ctx, Pending::AskForAFile);
579 }
580 let open = self.path.is_some();
581 if ui
582 .add_enabled(open, egui::Button::new(t("Reload")))
583 .clicked()
584 {
585 ui.close();
586 self.request(ctx, Pending::Reload);
587 }
588 if ui
589 .add_enabled(open, egui::Button::new(t("Close")))
590 .clicked()
591 {
592 ui.close();
593 self.request(ctx, Pending::Close);
594 }
595 ui.separator();
596 let modified = open && self.editing.modified();
597 if ui
598 .add_enabled(modified, egui::Button::new(t("Save")))
599 .clicked()
600 {
601 ui.close();
602 self.save();
603 }
604 if ui
605 .add_enabled(open, egui::Button::new(t("Save as…")))
606 .clicked()
607 {
608 ui.close();
609 self.save_as();
610 }
611 ui.separator();
612 if ui.button(t("Quit")).clicked() {
613 ui.close();
614 self.request(ctx, Pending::Quit);
615 }
616 });
617 ui.menu_button(t("Edit"), |ui| self.edit_menu(ui));
618 if self.view.is_open() {
619 self.view.menus(ui, &self.editing);
620 }
621 ui.menu_button(t("View"), |ui| {
622 if ui.button(t("Zoom in")).clicked() {
623 self.zoom = (self.zoom * ZOOM_STEP).min(ZOOM_MAX);
624 }
625 if ui.button(t("Zoom out")).clicked() {
626 self.zoom = (self.zoom / ZOOM_STEP).max(ZOOM_MIN);
627 }
628 if ui.button(t("Actual size")).clicked() {
629 self.zoom = 1.0;
630 }
631 ui.separator();
632 ui.checkbox(&mut self.show_side, t("Show the side panel"));
633 self.view.view_menu(ui);
634 });
635 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
636 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
637 let percent = (self.zoom * 100.0).round() as u32;
638 ui.label(fill(t("{percent}%"), &[("percent", &percent.to_string())]));
639 if self.editing.on && self.view.is_open() {
640 ui.separator();
641 ui.strong(t("Editing"));
642 }
643 });
644 });
645 }
646
647 fn edit_menu(&mut self, ui: &mut Ui) {
650 if ui
651 .add_enabled(self.editing.can_undo(), egui::Button::new(t("Undo")))
652 .clicked()
653 {
654 ui.close();
655 self.undo();
656 }
657 if ui
658 .add_enabled(self.editing.can_redo(), egui::Button::new(t("Redo")))
659 .clicked()
660 {
661 ui.close();
662 self.redo();
663 }
664 ui.separator();
665 if ui
666 .add_enabled(self.view.is_open(), egui::Button::new(t("Find…")))
667 .clicked()
668 {
669 ui.close();
670 self.find.open();
671 }
672 if ui
673 .add_enabled(
674 self.view.is_open() && self.view.can_replace(&self.editing),
675 egui::Button::new(t("Replace…")),
676 )
677 .clicked()
678 {
679 ui.close();
680 self.find.open_replacing();
681 }
682 ui.separator();
683 ui.checkbox(&mut self.editing.on, t("Edit mode"));
684 if ui
685 .checkbox(
686 &mut self.settings.open_in_edit_mode,
687 t("Open documents in edit mode"),
688 )
689 .changed()
690 && let Err(e) = self.settings.save()
691 {
692 self.error = Some(fill(
693 t("The setting could not be saved: {reason}"),
694 &[("reason", &e.to_string())],
695 ));
696 }
697 }
698
699 fn search(&mut self, ui: &mut Ui) {
702 if !self.find.is_open() || !self.view.is_open() {
703 if self.find.searching {
704 self.find.searching = false;
705 self.find.count = 0;
706 self.view.find("");
707 }
708 return;
709 }
710 let can_replace = self.view.can_replace(&self.editing);
711 let mut pressed = crate::find_bar::Pressed::default();
712 egui::Panel::top("find").show(ui, |ui| pressed = self.find.ui(ui, can_replace));
713 if !self.find.is_open() {
714 return;
715 }
716 if let Some(query_changed) = self.find.due(self.editing.revision()) {
717 self.find.searching = true;
718 self.find.count = self.view.find(self.find.query());
719 if query_changed {
720 self.find.current = 0;
721 if self.find.count > 0 {
722 self.view.show_match(0);
723 }
724 } else {
725 self.find.current = self.find.current.min(self.find.count.saturating_sub(1));
726 }
727 }
728 if let Some(step) = pressed.step {
729 self.step_match(step);
730 }
731 if let Some(which) = pressed.replace {
732 self.replace(which);
733 }
734 }
735
736 fn replace(&mut self, which: Replace) {
737 let with = self.find.replacement().to_owned();
738 let done = self
739 .view
740 .replace(&with, which == Replace::All, &mut self.editing);
741 if which == Replace::One
744 && done.replaced > 0
745 && !crate::find::ranges(&with, self.find.query()).is_empty()
746 {
747 self.find.current += 1;
748 }
749 let replaced = done.replaced.to_string();
750 self.find.say(if done.skipped == 0 {
751 fill(t("{replaced} replaced"), &[("replaced", &replaced)])
752 } else {
753 fill(
754 t("{replaced} replaced, {skipped} left"),
755 &[
756 ("replaced", &replaced),
757 ("skipped", &done.skipped.to_string()),
758 ],
759 )
760 });
761 }
762
763 fn step_match(&mut self, step: Step) {
764 let count = self.find.count;
765 if count == 0 {
766 return;
767 }
768 self.find.current = match step {
769 Step::Next => (self.find.current + 1) % count,
770 Step::Previous => (self.find.current + count - 1) % count,
771 };
772 self.view.show_match(self.find.current);
773 }
774
775 fn nothing_open(&mut self, ui: &mut Ui) {
777 ui.vertical_centered(|ui| {
778 ui.add_space(ui.available_height() * 0.3);
779 ui.heading(self.product.id);
783 ui.label(t(self.product.format));
784 ui.add_space(12.0);
785 if ui.button(t("Open a document…")).clicked() {
786 let ctx = ui.ctx().clone();
787 self.ask_for_a_file(&ctx);
788 }
789 });
790 }
791
792 fn find_keys(&mut self, ctx: &egui::Context) {
795 if !self.view.is_open() {
796 return;
797 }
798 let pressed = |modifiers, key| {
799 ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(modifiers, key)))
800 };
801 if pressed(Modifiers::COMMAND, Key::F) {
802 self.find.open();
803 }
804 if pressed(Modifiers::COMMAND, Key::H) {
805 self.find.open_replacing();
806 }
807 if pressed(Modifiers::SHIFT, Key::F3) {
808 self.step_match(Step::Previous);
809 }
810 if pressed(Modifiers::NONE, Key::F3) {
811 if self.find.is_open() {
812 self.step_match(Step::Next);
813 } else {
814 self.find.open();
815 }
816 }
817 }
818
819 fn keys(&mut self, ctx: &egui::Context) {
820 let pressed = |key| {
821 ctx.input_mut(|input| {
822 input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, key))
823 })
824 };
825 let shifted = |key| {
826 ctx.input_mut(|input| {
827 input.consume_shortcut(&KeyboardShortcut::new(
828 Modifiers::COMMAND | Modifiers::SHIFT,
829 key,
830 ))
831 })
832 };
833 if shifted(Key::S) {
835 self.save_as();
836 }
837 if shifted(Key::Z) || pressed(Key::Y) {
838 self.redo();
839 }
840 if pressed(Key::S) && self.editing.modified() {
841 self.save();
842 }
843 if pressed(Key::Z) {
844 self.undo();
845 }
846 if pressed(Key::E) && self.view.is_open() {
847 self.editing.on = !self.editing.on;
848 }
849 if pressed(Key::O) {
850 self.request(ctx, Pending::AskForAFile);
851 }
852 if pressed(Key::R) {
853 self.request(ctx, Pending::Reload);
854 }
855 if pressed(Key::W) {
856 self.request(ctx, Pending::Close);
857 }
858 if pressed(Key::Plus) || pressed(Key::Equals) {
861 self.zoom = (self.zoom * ZOOM_STEP).min(ZOOM_MAX);
862 }
863 if pressed(Key::Minus) {
864 self.zoom = (self.zoom / ZOOM_STEP).max(ZOOM_MIN);
865 }
866 if pressed(Key::Num0) {
867 self.zoom = 1.0;
868 }
869 }
870}
871
872fn name_of(path: &Path) -> String {
874 path.file_name().map_or_else(
875 || path.display().to_string(),
876 |name| name.to_string_lossy().into_owned(),
877 )
878}
879
880fn replace_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
896 #[cfg(target_os = "macos")]
897 {
898 std::fs::write(path, bytes)
899 }
900 #[cfg(not(target_os = "macos"))]
901 {
902 use std::io::Write as _;
903 let temporary = path.with_file_name(format!("{}.part", name_of(path)));
904 let mut file = std::fs::File::create(&temporary)?;
905 let written = file
906 .write_all(bytes)
907 .and_then(|()| file.sync_all())
908 .and_then(|()| match std::fs::metadata(path) {
909 Ok(existing) => std::fs::set_permissions(&temporary, existing.permissions()),
910 Err(_) => Ok(()),
911 })
912 .and_then(|()| std::fs::rename(&temporary, path));
913 if written.is_err() {
914 let _ = std::fs::remove_file(&temporary);
917 }
918 written
919 }
920}
921
922pub fn run<V: View + 'static>(
929 product: Product,
930 build: impl FnOnce(&egui::Context) -> V + 'static,
931) -> eframe::Result {
932 crate::i18n::start();
933
934 let id = product.id;
935 let viewport = egui::ViewportBuilder::default()
936 .with_app_id(id)
939 .with_title(id)
940 .with_inner_size([1000.0, 760.0])
941 .with_min_inner_size([420.0, 320.0]);
942
943 #[cfg(target_os = "macos")]
963 let viewport = viewport.with_icon(egui::IconData::default());
964
965 #[cfg(target_os = "windows")]
969 let viewport = match window_icon(product.icon) {
970 Some(icon) => viewport.with_icon(icon),
971 None => viewport,
972 };
973
974 let options = eframe::NativeOptions {
975 viewport,
976 ..eframe::NativeOptions::default()
977 };
978
979 #[cfg(target_os = "macos")]
983 crate::opened_document::watch();
984
985 let first = std::env::args_os().nth(1).map(PathBuf::from);
986 eframe::run_native(
987 id,
988 options,
989 Box::new(move |cc| {
990 crate::system_theme::follow(&cc.egui_ctx);
991 #[cfg(target_os = "macos")]
994 crate::opened_document::wake_with(&cc.egui_ctx);
995 let mut shell = Shell::new(product, build(&cc.egui_ctx));
996 if let Some(path) = first {
997 shell.open(&cc.egui_ctx, &path);
998 }
999 Ok(Box::new(shell))
1000 }),
1001 )
1002}
1003
1004#[cfg(target_os = "windows")]
1012fn window_icon(bytes: &'static [u8]) -> Option<egui::IconData> {
1013 if bytes.is_empty() {
1014 return None;
1015 }
1016 let directory = ico::IconDir::read(std::io::Cursor::new(bytes)).ok()?;
1017 let entry = directory
1018 .entries()
1019 .iter()
1020 .find(|entry| entry.width() == 64)
1021 .or_else(|| directory.entries().last())?;
1022 let image = entry.decode().ok()?;
1023 Some(egui::IconData {
1024 width: image.width(),
1025 height: image.height(),
1026 rgba: image.rgba_data().to_vec(),
1027 })
1028}
1029
1030#[cfg(test)]
1031mod tests {
1032 use super::replace_file;
1033
1034 #[test]
1037 fn a_file_is_replaced_whole() {
1038 let directory = std::env::temp_dir().join(format!("odox-replace-{}", std::process::id()));
1039 std::fs::create_dir_all(&directory).expect("a scratch directory");
1040 let path = directory.join("document.odt");
1041 std::fs::write(&path, b"before").expect("the original");
1042 let mode = std::fs::metadata(&path)
1043 .expect("its metadata")
1044 .permissions();
1045
1046 replace_file(&path, b"after").expect("the replacement");
1047
1048 assert_eq!(std::fs::read(&path).expect("the file"), b"after");
1049 assert_eq!(
1050 std::fs::metadata(&path)
1051 .expect("its metadata")
1052 .permissions(),
1053 mode
1054 );
1055 let left: Vec<_> = std::fs::read_dir(&directory)
1056 .expect("the directory")
1057 .filter_map(Result::ok)
1058 .map(|entry| entry.file_name())
1059 .collect();
1060 assert_eq!(left, vec![std::ffi::OsString::from("document.odt")]);
1061 std::fs::remove_dir_all(directory).expect("tidy");
1062 }
1063
1064 #[test]
1066 fn a_failed_write_leaves_no_part_file() {
1067 let directory = std::env::temp_dir().join(format!("odox-refuse-{}", std::process::id()));
1068 std::fs::create_dir_all(&directory).expect("a scratch directory");
1069 let missing = directory.join("nowhere").join("document.odt");
1070 assert!(replace_file(&missing, b"x").is_err());
1071 assert_eq!(
1072 std::fs::read_dir(&directory)
1073 .expect("the directory")
1074 .count(),
1075 0
1076 );
1077 std::fs::remove_dir_all(directory).expect("tidy");
1078 }
1079}