//! Workspace, file-list and git operations: opening a folder, creating, //! renaming and deleting manuscript files, persisting order and titles, and //! exporting the assembled manuscript to ODT. use super::*; impl App { pub(super) fn workspace(&self) -> &Path { &self.config.workspace } /// (Re)load the file list for the current workspace, creating the directory /// if needed, and refresh git status. pub(super) fn open_workspace(&mut self) { let ws = self.config.workspace.clone(); if let Err(e) = std::fs::create_dir_all(&ws) { self.status = format!("Cannot create workspace: {e}"); return; } self.files = order::resolve_order(&ws); self.titles = order::read_titles(&ws); // Drop overrides for files that no longer exist. self.titles.retain(|name, _| self.files.contains(name)); self.detect_repo(&ws); self.selected = None; self.buffer.clear(); self.dirty = false; self.pending_delete = false; self.clear_lt(); self.session_start_counts = self.snapshot_counts(); self.file_meta = self.snapshot_file_meta(); self.rebuild_field_names(); self.autocomplete = None; self.collapsed.clear(); if !self.files.is_empty() { self.select(0); } self.persist_order(); self.status = format!("{} file(s) in {}", self.files.len(), ws.display()); } /// Work out the git situation for a freshly opened workspace. /// /// If the folder is itself a repository root it is adopted silently. If it /// is not, but an enclosing parent directory is a git work tree, we stash /// that parent in `pending_repo` and ask the user to confirm before using /// it (see [`repo_prompt`](Self::repo_prompt)). Otherwise the workspace is /// treated as having no repository. pub(super) fn detect_repo(&mut self, ws: &Path) { self.pending_repo = None; // A `.git` entry directly in the folder (dir, or a file for linked // worktrees/submodules) means this folder is the repo root. if ws.join(".git").exists() { self.is_repo = true; self.repo_root = Some(ws.to_path_buf()); return; } match gitsync::repo_root(ws) { // A parent directory is a repository — ask before adopting it. Some(root) if root != ws => { self.is_repo = false; self.repo_root = None; self.pending_repo = Some(root); } // `--show-toplevel` reported this very folder (shouldn't happen // without a `.git` here, but treat it as an ordinary repo root). Some(root) => { self.is_repo = true; self.repo_root = Some(root); } None => { self.is_repo = false; self.repo_root = None; } } } /// Adopt the parent repository awaiting confirmation, using it for all git /// operations on the current workspace. pub(super) fn adopt_pending_repo(&mut self) { if let Some(root) = self.pending_repo.take() { self.status = format!("Using git repository at {}", root.display()); self.is_repo = true; self.repo_root = Some(root); } } /// Decline the parent repository; the workspace stays without version /// control (the user can still `Init git` to create a nested repo). pub(super) fn decline_pending_repo(&mut self) { self.pending_repo = None; self.is_repo = false; self.repo_root = None; self.status = "Not using the enclosing git repository".to_string(); } /// Read every file and return its current on-disk word count. pub(super) fn snapshot_counts(&self) -> HashMap { self.files .iter() .map(|name| { let text = std::fs::read_to_string(self.path_for(name)).unwrap_or_default(); (name.clone(), count_words(&text)) }) .collect() } /// Read every file and return its cached header info (slug/POV/goal/prose), /// for files that carry anything worth showing in the list. pub(super) fn snapshot_file_meta(&self) -> HashMap { self.files .iter() .filter_map(|name| { let text = std::fs::read_to_string(self.path_for(name)).unwrap_or_default(); let meta = FileMeta::from_markdown(&text, &self.config.draft_marker); meta.has_display().then(|| (name.clone(), meta)) }) .collect() } pub(super) fn persist_order(&self) { let _ = order::write_order(self.workspace(), &self.files); } pub(super) fn persist_titles(&self) { let _ = order::write_titles(self.workspace(), &self.titles); } /// Store the title override for the selected file from `title_input`. /// An empty value removes the override (falls back to the auto title). pub(super) fn set_title_for_current(&mut self) { if let Some(idx) = self.selected { if let Some(name) = self.files.get(idx).cloned() { let trimmed = self.title_input.trim(); if trimmed.is_empty() { self.titles.remove(&name); } else { self.titles.insert(name, trimmed.to_string()); } self.persist_titles(); } } } pub(super) fn path_for(&self, name: &str) -> PathBuf { self.workspace().join(name) } /// Save the in-memory buffer to disk if it has unsaved changes. pub(super) fn save_current(&mut self) { if let Some(idx) = self.selected { if self.dirty { if let Some(name) = self.files.get(idx).cloned() { let path = self.path_for(&name); match std::fs::write(&path, &self.buffer) { Ok(_) => { self.dirty = false; self.status = format!("Saved {name}"); // Keep the file-list cache (slug/POV/goal/prose) in step. let meta = FileMeta::from_markdown(&self.buffer, &self.config.draft_marker); if meta.has_display() { self.file_meta.insert(name, meta); } else { self.file_meta.remove(&name); } self.merge_field_names_from_buffer(); } Err(e) => self.status = format!("Save failed: {e}"), } } } } } pub(super) fn select(&mut self, idx: usize) { if self.selected == Some(idx) { return; } self.save_current(); self.clear_lt(); // Matches from the previous file's buffer are meaningless now. self.find_matches.clear(); self.find_active = 0; self.find_needs_refresh = true; // Re-run the live spell check on the newly loaded buffer immediately. self.spell_matches.clear(); self.spell_checked_text.clear(); self.spell_dirty = true; self.spell_last_edit = None; self.spell_menu = None; let Some(name) = self.files.get(idx).cloned() else { return; }; let path = self.path_for(&name); self.buffer = std::fs::read_to_string(&path).unwrap_or_default(); self.selected = Some(idx); self.dirty = false; self.pending_delete = false; // The rename box holds the whole workspace-relative path, so it doubles // as the way to move a file between folders by typing. self.rename_input = strip_md(&name).to_string(); self.title_input = self.titles.get(&name).cloned().unwrap_or_default(); self.reveal(&name); } /// Expand any collapsed folders that would hide `path`, so a file that was /// just selected or created is actually on screen. pub(super) fn reveal(&mut self, path: &str) { self.collapsed.retain(|dir| !is_within(path, dir)); } /// Move one manuscript file on disk and carry its per-file state — title /// override, session word baseline, cached header info — across to the new /// path. The caller is responsible for updating `files`; on error nothing /// has changed. fn relocate(&mut self, old: &str, new: &str) -> std::io::Result<()> { move_manuscript_file(self.workspace(), old, new)?; if let Some(title) = self.titles.remove(old) { self.titles.insert(new.to_string(), title); self.persist_titles(); } if let Some(words) = self.session_start_counts.remove(old) { self.session_start_counts.insert(new.to_string(), words); } if let Some(meta) = self.file_meta.remove(old) { self.file_meta.insert(new.to_string(), meta); } Ok(()) } pub(super) fn create_file(&mut self) { // The typed name may carry folders (`part-1/ch-01`), which is the only // way new folders come into being — one appears with its first file. let Some(name) = sanitize_rel_path(&self.new_name) else { self.status = "Enter a name for the new file".to_string(); return; }; let stem = strip_md(base_name(&name)).to_string(); let seed = format!("# {stem}\n\n"); self.insert_new_file(name, seed); } /// Create a file seeded from the configured template, naming it /// `untitled-N.md` so the button works without typing a name first. It lands /// beside the file being edited, so working inside a part folder doesn't /// scatter untitled files back at the workspace root. pub(super) fn create_file_from_template(&mut self) { let dir = self .selected .and_then(|i| self.files.get(i)) .map(|path| parent_dir(path).to_string()) .unwrap_or_default(); let existing: std::collections::HashSet = self.files.iter().map(|n| n.to_lowercase()).collect(); let leaf = next_untitled_name(|candidate| { let full = join_rel(&dir, candidate); existing.contains(&full.to_lowercase()) || self.path_for(&full).exists() }); let stem = strip_md(&leaf).to_string(); let seed = render_template( &self.config.effective_new_file_template(), &stem, self.config.draft_marker.trim(), &today_utc(), ); self.insert_new_file(join_rel(&dir, &leaf), seed); // The template's header fields should be offerable straight away. self.rebuild_field_names(); } /// Write `contents` to a new file, add it to the manuscript order and select /// it. Shared by the plain and template-backed create paths. fn insert_new_file(&mut self, name: String, contents: String) { let path = self.path_for(&name); if path.exists() { self.status = format!("{name} already exists"); return; } if let Some(parent) = path.parent() { if let Err(e) = std::fs::create_dir_all(parent) { self.status = format!("Create failed: {e}"); return; } } match std::fs::write(&path, contents) { Ok(_) => { self.files.push(name.clone()); // Folder-tree order decides where the newcomer actually lands, // so normalise before working out which index to select. self.files = order::tree_order(&self.files); self.persist_order(); let idx = self.files.iter().position(|f| *f == name).unwrap_or(0); self.selected = None; // force reload of buffer self.select(idx); self.new_name.clear(); self.status = format!("Created {name}"); } Err(e) => self.status = format!("Create failed: {e}"), } } pub(super) fn delete_selected(&mut self) { if let Some(idx) = self.selected { if let Some(name) = self.files.get(idx).cloned() { let path = self.path_for(&name); match std::fs::remove_file(&path) { Ok(_) => { self.files.remove(idx); self.titles.remove(&name); self.session_start_counts.remove(&name); self.file_meta.remove(&name); self.persist_titles(); self.persist_order(); // The folder may have held nothing else. prune_empty_dirs(self.workspace(), parent_dir(&name)); self.selected = None; self.buffer.clear(); self.dirty = false; if !self.files.is_empty() { self.select(idx.min(self.files.len() - 1)); } self.status = format!("Deleted {name}"); } Err(e) => self.status = format!("Delete failed: {e}"), } } } self.pending_delete = false; } pub(super) fn rename_selected(&mut self) { let Some(idx) = self.selected else { return }; // A path in the box (`part-2/ch-07`) both renames and moves the file. let Some(new_name) = sanitize_rel_path(&self.rename_input) else { self.status = "Enter a new name".to_string(); return; }; let Some(old_name) = self.files.get(idx).cloned() else { return; }; if new_name == old_name { return; } if self.path_for(&new_name).exists() { self.status = format!("{new_name} already exists"); return; } // Persist any pending edits under the old name first. self.save_current(); match self.relocate(&old_name, &new_name) { Ok(_) => { self.files[idx] = new_name.clone(); self.files = order::tree_order(&self.files); self.selected = self.files.iter().position(|f| *f == new_name); self.persist_order(); self.reveal(&new_name); self.status = format!("Renamed to {new_name}"); } Err(e) => self.status = format!("Rename failed: {e}"), } } pub(super) fn git_init(&mut self) { // Initialising creates a repository in the workspace itself, which // supersedes any enclosing repo we were about to ask about. self.pending_repo = None; let ws = self.workspace().to_path_buf(); let outcome = gitsync::init(&ws); self.git_log = outcome.log; self.show_log = true; self.is_repo = gitsync::is_repo(&ws); self.repo_root = self.is_repo.then_some(ws); self.status = if self.is_repo { "Initialised git repository".to_string() } else { "git init failed (see log)".to_string() }; } pub(super) fn git_sync(&mut self) { self.save_current(); self.persist_order(); self.persist_titles(); let msg = format!( "Sync manuscript {}", chrono_like_timestamp() ); let outcome = gitsync::sync(self.workspace(), &msg); self.git_log = outcome.log; self.show_log = true; self.status = if outcome.ok { "Sync complete".to_string() } else { "Sync finished with errors (see log)".to_string() }; } /// Digits to zero-pad a defaulted chapter number to: the width of the largest /// chapter number when padding is enabled, otherwise 1 (no padding). pub(super) fn index_pad_width(&self) -> usize { if self.config.zero_pad_index { self.files.len().to_string().len().max(1) } else { 1 } } /// The title a chapter at `idx` would get without a manual override: its /// `# Title:` header, or its 1-based position (e.g. "3."). Shown as the hint /// in the chapter-title field. pub(super) fn auto_title(&self, idx: usize, markdown: &str) -> String { let header = crate::preprocess::parse(markdown, &self.config.draft_marker); resolve_chapter_title(None, header.title.as_deref(), idx, self.index_pad_width()) } /// The current file's word-count target (from its `Word Count Target:` /// header) paired with its current prose word count, if a target is set. /// Prose = the body below the draft marker, so header metadata isn't counted. pub(super) fn current_goal(&self) -> Option<(crate::preprocess::WordGoal, usize)> { self.selected?; let header = crate::preprocess::parse(&self.buffer, &self.config.draft_marker); let goal = header.goal?; Some((goal, count_words(&header.body))) } pub(super) fn export_odt(&mut self) { self.save_current(); let marker = self.config.draft_marker.clone(); let pad_width = self.index_pad_width(); let mut chapters = Vec::new(); for (i, name) in self.files.iter().enumerate() { let raw = std::fs::read_to_string(self.path_for(name)).unwrap_or_default(); // Strip comments + the editorial header, and lift out any // `# Title:` / `# Slug:` metadata. let header = crate::preprocess::parse(&raw, &marker); // Title priority: manual override > `# Title:` metadata > the chapter's // 1-based position (e.g. "3."). The body from `parse` is used as-is. let title = resolve_chapter_title( self.titles.get(name).map(String::as_str), header.title.as_deref(), i, pad_width, ); chapters.push(Chapter { title, slug: header.slug.clone(), markdown: header.body, }); } let out = PathBuf::from(self.export_input.trim()); if let Some(parent) = out.parent() { let _ = std::fs::create_dir_all(parent); } match odt::export(&chapters, &out) { Ok(_) => { self.config.export_path = out.clone(); self.config.save(); self.status = format!("Exported {} chapter(s) to {}", chapters.len(), out.display()); } Err(e) => self.status = format!("Export failed: {e}"), } } /// Open a native folder picker to choose the workspace directory. pub(super) fn browse_workspace(&mut self) { let start = PathBuf::from(self.workspace_input.trim()); let mut dialog = rfd::FileDialog::new().set_title("Choose workspace folder"); if start.is_dir() { dialog = dialog.set_directory(&start); } if let Some(path) = dialog.pick_folder() { self.save_current(); self.workspace_input = path.display().to_string(); self.config.workspace = path; self.config.save(); self.open_workspace(); } } /// Open a native save dialog to choose the export `.odt` path. pub(super) fn browse_export(&mut self) { let current = PathBuf::from(self.export_input.trim()); let mut dialog = rfd::FileDialog::new() .set_title("Choose export file") .add_filter("OpenDocument Text", &["odt"]); if let Some(parent) = current.parent().filter(|p| p.is_dir()) { dialog = dialog.set_directory(parent); } let name = current .file_name() .and_then(|s| s.to_str()) .unwrap_or("manuscript.odt"); if let Some(mut path) = dialog.set_file_name(name).save_file() { // Ensure the chosen path ends in .odt even if the user omitted it. let has_odt = path .extension() .and_then(|e| e.to_str()) .is_some_and(|e| e.eq_ignore_ascii_case("odt")); if !has_odt { path.set_extension("odt"); } self.export_input = path.display().to_string(); self.config.export_path = path; self.config.save(); } } /// Apply a file-panel drag-and-drop: move the file into the drop's folder on /// disk when that changed, then reposition it in the manuscript order. pub(super) fn apply_drop(&mut self, drop: FileDrop) { let FileDrop { from, to, dir } = drop; let Some(old) = self.files.get(from).cloned() else { return; }; if parent_dir(&old) == dir { self.status = "Reordered".to_string(); } else { let new = join_rel(&dir, base_name(&old)); if self.path_for(&new).exists() { self.status = format!("{new} already exists"); return; } // Flush pending edits under the old name before the file moves. self.save_current(); if let Err(e) = self.relocate(&old, &new) { self.status = format!("Move failed: {e}"); return; } self.files[from] = new; self.status = match dir.as_str() { "" => format!("Moved {} to the workspace root", base_name(&old)), dir => format!("Moved {} into {dir}", base_name(&old)), }; } self.reorder(from, to); } /// Move the file at `from` to flat position `to`, then re-normalise into /// folder-tree order so the panel and the export stay in step. pub(super) fn reorder(&mut self, from: usize, mut to: usize) { if from >= self.files.len() { return; } // Remember the selected file by name so selection follows the move. let selected_name = self.selected.and_then(|i| self.files.get(i)).cloned(); let item = self.files.remove(from); if from < to { to -= 1; } to = to.min(self.files.len()); self.files.insert(to, item); self.files = order::tree_order(&self.files); self.persist_order(); if let Some(name) = selected_name { self.selected = self.files.iter().position(|n| *n == name); } } /// Settings dialog for the markdown seeded into template-backed new files. pub(super) fn template_settings_window(&mut self, ctx: &egui::Context) { let mut open = self.show_template_settings; let mut close_clicked = false; egui::Window::new("New-file template") .open(&mut open) .resizable(true) .collapsible(false) .default_width(430.0) .show(ctx, |ui| { let mut save_now = false; ui.label( egui::RichText::new( "Seeded into files made with “+ New from template” in the file list.", ) .small() .weak(), ); ui.add_space(4.0); let r = ui.add( egui::TextEdit::multiline(&mut self.config.new_file_template) .code_editor() .desired_width(f32::INFINITY) .desired_rows(12), ); save_now |= r.lost_focus(); ui.add_space(4.0); ui.label(egui::RichText::new("Placeholders").strong()); egui::Grid::new("template_placeholders") .num_columns(2) .spacing([10.0, 2.0]) .show(ui, |ui| { for (token, meaning) in [ ("{{name}}", "the file stem, e.g. untitled-3"), ("{{marker}}", "the draft marker set in the toolbar"), ("{{date}}", "today's date (UTC), as YYYY-MM-DD"), ] { ui.label(egui::RichText::new(token).monospace()); ui.label(egui::RichText::new(meaning).weak()); ui.end_row(); } }); ui.add_space(4.0); ui.label( egui::RichText::new( "Leave the box empty to go back to the built-in default.", ) .small() .weak(), ); ui.separator(); ui.horizontal(|ui| { if ui.button("Close").clicked() { close_clicked = true; } if ui .button("Reset to default") .on_hover_text("Replace the box with the built-in template") .clicked() { self.config.new_file_template = crate::config::default_new_file_template(); save_now = true; } }); if save_now { self.config.save(); } }); let now_open = open && !close_clicked; if self.show_template_settings && !now_open { self.config.save(); } self.show_template_settings = now_open; } } /// Delete `dir` (workspace-relative) and every parent it leaves childless, so /// the tree stops drawing branches nothing lives in any more. `remove_dir` /// refuses to touch a non-empty directory, which is exactly the guard wanted /// here; an empty `dir` is the workspace itself and is left alone. pub(super) fn prune_empty_dirs(workspace: &Path, dir: &str) { let mut dir = dir; while !dir.is_empty() { if std::fs::remove_dir(workspace.join(dir)).is_err() { break; } dir = parent_dir(dir); } } /// Move a manuscript file within the workspace, creating the destination folder /// and pruning the source folder when the move empties it. pub(super) fn move_manuscript_file( workspace: &Path, old: &str, new: &str, ) -> std::io::Result<()> { let new_path = workspace.join(new); if let Some(parent) = new_path.parent() { std::fs::create_dir_all(parent)?; } std::fs::rename(workspace.join(old), &new_path)?; prune_empty_dirs(workspace, parent_dir(old)); Ok(()) } /// First free `untitled-N.md`, so the template button needs no typed name. /// `is_taken` reports names already used on disk or in the manuscript order. pub(super) fn next_untitled_name(is_taken: impl Fn(&str) -> bool) -> String { for n in 1..=9_999 { let name = format!("untitled-{n}.md"); if !is_taken(&name) { return name; } } // Absurdly unlikely; fall back to something guaranteed unique-ish. format!("untitled-{}.md", chrono_like_timestamp().trim_start_matches('@')) } /// Expand the template placeholders and guarantee a trailing newline. pub(super) fn render_template(template: &str, stem: &str, marker: &str, date: &str) -> String { let mut out = template .replace("{{name}}", stem) .replace("{{marker}}", marker) .replace("{{date}}", date); if !out.ends_with('\n') { out.push('\n'); } out } #[cfg(test)] mod tests { use super::*; #[test] fn untitled_name_fills_the_first_free_slot() { assert_eq!(next_untitled_name(|_| false), "untitled-1.md"); let taken = ["untitled-1.md", "untitled-2.md"]; assert_eq!( next_untitled_name(|n| taken.contains(&n)), "untitled-3.md" ); // A gap is reused rather than skipped. let sparse = ["untitled-1.md", "untitled-3.md"]; assert_eq!( next_untitled_name(|n| sparse.contains(&n)), "untitled-2.md" ); } #[test] fn render_template_expands_every_placeholder() { let out = render_template( "# Title: {{name}}\n# Started: {{date}}\n\n{{marker}}\n", "untitled-7", "### Rough Draft:", "2026-08-22", ); assert_eq!( out, "# Title: untitled-7\n# Started: 2026-08-22\n\n### Rough Draft:\n" ); } #[test] fn render_template_repeats_and_leaves_unknown_tokens_alone() { let out = render_template("{{name}}/{{name}} {{nope}}", "a", "M", "D"); assert_eq!(out, "a/a {{nope}}\n"); } #[test] fn render_template_always_ends_with_a_newline() { assert!(render_template("no trailing newline", "n", "m", "d").ends_with('\n')); // An already-terminated template doesn't collect a second one. assert_eq!(render_template("done\n", "n", "m", "d"), "done\n"); } #[test] fn render_template_tolerates_an_empty_marker() { // An empty draft_marker disables header splitting; the line goes blank. assert_eq!(render_template("a\n{{marker}}\nb\n", "n", "", "d"), "a\n\nb\n"); } /// The point of the default template is that the app's own header parser /// understands what it produces — otherwise the seeded fields are just text. #[test] fn default_template_round_trips_through_the_header_parser() { let marker = crate::config::default_marker(); let seeded = render_template( &crate::config::default_new_file_template(), "chapter-01", &marker, "2026-08-22", ); let h = crate::preprocess::parse(&seeded, &marker); assert_eq!(h.title.as_deref(), Some("chapter-01")); // Slug/POV are seeded blank, so they parse as absent rather than empty. assert_eq!(h.slug, None); assert_eq!(h.pov, None); assert_eq!(h.goal, None); // Nothing above the marker leaks into the exported prose. assert_eq!(h.body.trim(), ""); } /// A filled-in template parses into the fields the file list displays. #[test] fn filled_template_parses_into_header_fields() { let marker = crate::config::default_marker(); let seeded = render_template( "# Title: {{name}}\n# Slug: the door\n# POV: Ada\n\ # Word Count Target: 1500 - 2000\n\n{{marker}}\n\nReal prose here.\n", "chapter-02", &marker, "2026-08-22", ); let h = crate::preprocess::parse(&seeded, &marker); assert_eq!(h.title.as_deref(), Some("chapter-02")); assert_eq!(h.slug.as_deref(), Some("the door")); assert_eq!(h.pov.as_deref(), Some("Ada")); assert!(h.goal.is_some(), "word-count target should parse"); assert_eq!(h.body.trim(), "Real prose here."); } #[test] fn default_template_renders_to_a_usable_header() { let out = render_template( &crate::config::default_new_file_template(), "chapter-01", "### Rough Draft:", "2026-08-22", ); assert!(out.starts_with("# Title: chapter-01\n"), "got {out:?}"); assert!(out.contains("\n### Rough Draft:\n"), "got {out:?}"); assert!(!out.contains("{{"), "placeholder left unexpanded in {out:?}"); } /// Build a throwaway workspace containing `files` and hand back its path. fn scratch_ws(tag: &str, files: &[&str]) -> PathBuf { let dir = std::env::temp_dir().join(format!("md_manuscript_ws_{tag}")); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); for rel in files { let path = dir.join(rel); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, "x").unwrap(); } dir } #[test] fn moving_a_file_creates_the_destination_and_clears_the_source_folder() { let ws = scratch_ws("move", &["part-1/ch-01.md"]); move_manuscript_file(&ws, "part-1/ch-01.md", "part-2/ch-01.md").unwrap(); assert!(ws.join("part-2/ch-01.md").is_file()); assert!(!ws.join("part-1").exists(), "the emptied folder should go"); let _ = std::fs::remove_dir_all(&ws); } #[test] fn moving_a_file_leaves_a_folder_that_still_holds_others() { let ws = scratch_ws("move_keep", &["p/a.md", "p/b.md"]); move_manuscript_file(&ws, "p/a.md", "a.md").unwrap(); assert!(ws.join("a.md").is_file()); assert!(ws.join("p/b.md").is_file()); assert!(ws.join("p").is_dir(), "a folder with files left must survive"); let _ = std::fs::remove_dir_all(&ws); } #[test] fn moving_into_a_folder_that_does_not_exist_yet_creates_it() { let ws = scratch_ws("move_new", &["a.md"]); move_manuscript_file(&ws, "a.md", "part-3/deep/a.md").unwrap(); assert!(ws.join("part-3/deep/a.md").is_file()); let _ = std::fs::remove_dir_all(&ws); } #[test] fn moving_a_missing_file_fails_without_disturbing_the_workspace() { let ws = scratch_ws("move_missing", &["a.md"]); assert!(move_manuscript_file(&ws, "nope.md", "p/nope.md").is_err()); assert!(ws.join("a.md").is_file()); let _ = std::fs::remove_dir_all(&ws); } #[test] fn pruning_walks_up_through_every_folder_it_empties() { let ws = scratch_ws("prune", &["a/b/c/only.md", "keep.md"]); std::fs::remove_file(ws.join("a/b/c/only.md")).unwrap(); prune_empty_dirs(&ws, "a/b/c"); assert!(!ws.join("a").exists(), "the whole empty chain should go"); assert!(ws.join("keep.md").is_file()); let _ = std::fs::remove_dir_all(&ws); } #[test] fn pruning_stops_at_the_first_folder_still_holding_something() { let ws = scratch_ws("prune_stop", &["a/keep.md", "a/b/gone.md"]); std::fs::remove_file(ws.join("a/b/gone.md")).unwrap(); prune_empty_dirs(&ws, "a/b"); assert!(!ws.join("a/b").exists()); assert!(ws.join("a").is_dir(), "`a` still holds keep.md"); let _ = std::fs::remove_dir_all(&ws); } #[test] fn pruning_never_removes_the_workspace_itself() { let ws = scratch_ws("prune_root", &[]); prune_empty_dirs(&ws, ""); assert!(ws.is_dir()); let _ = std::fs::remove_dir_all(&ws); } }