From 37a7bab30fd2aa670a19b3b3ec1422b9ae037441 Mon Sep 17 00:00:00 2001 From: MDietermann Date: Fri, 28 Aug 2026 09:38:38 +0200 Subject: [PATCH 1/2] feat: Workspace-Liste laden und speichern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die anwendungsweite Liste bekannter Workspaces, so wie sie in den Issues #3 und #4 festgelegt wurde. Bewusst ohne Oberfläche, damit dieser Teil sich mit echten Tests pruefen laesst statt nur mit cargo check. Ablageort ueber einen einzigen Ausdruck fuer alle Plattformen: state_dir().or_else(config_local_dir) - state_dir() liefert nur unter Linux Some, wo die XDG-Spec "recently used files" ausdruecklich als State fuehrt, also nach $XDG_STATE_HOME und nicht nach ~/.config. Unter Windows faellt es auf %LOCALAPPDATA% durch, bewusst nicht Roaming: absolute Workspace-Pfade sind maschinenspezifisch und zeigten auf einem anderen Rechner ins Leere. Verifiziert, ein Test haelt den Ort fest. Die Liste enthaelt nur Pfade. Kein Anzeigename, weil der Ordnername ihn liefert und ein eigenes Feld nach dem ersten Umbenennen falsch waere. Kein Zeitstempel, weil die Reihenfolge selbst die Sortierung ist - promote() hebt einen Eintrag an die Spitze und entfernt ihn vorher aus der Liste, wodurch Duplikate gar nicht erst entstehen koennen. Eine unlesbare Datei ergibt eine leere Liste statt eines Fehlers: Sie enthaelt nichts Unwiederbringliches, der Nutzer waehlt seine Ordner einmal neu. Genau deshalb braucht sie auch keine Schema-Version. Neu im Abhaengigkeitsgraph ist allein ron; dirs und serde lagen bereits transitiv vor. directories waere die teurere Variante gewesen - sein einziger Mehrwert waere die Kleinschreibung des Ordnernamens, die hier eine Konstante erledigt. --- Cargo.lock | 23 ++++++++ Cargo.toml | 3 + src/main.rs | 1 + src/workspace_list.rs | 130 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+) create mode 100644 src/workspace_list.rs diff --git a/Cargo.lock b/Cargo.lock index f8ce4ed..3827991 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2329,6 +2329,9 @@ dependencies = [ "dioxus", "dioxus-code", "dioxus-code-editor", + "dirs", + "ron", + "serde", "strum", ] @@ -5886,6 +5889,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ron" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" +dependencies = [ + "bitflags 2.13.1", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -7012,6 +7029,12 @@ dependencies = [ "utf-8", ] +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" version = "1.20.1" diff --git a/Cargo.toml b/Cargo.toml index 28a1d47..a70fd54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,9 @@ edition = "2021" dioxus = { version = "0.7.1", features = ["router"] } dioxus-code = "0.1.2" dioxus-code-editor = { version = "0.1.2", features = ["all-languages"] } +dirs = "6.0.0" +ron = "0.12.2" +serde = { version = "1.0.229", features = ["derive"] } strum = { version = "0.28.0", features = ["derive"] } [features] diff --git a/src/main.rs b/src/main.rs index 7e18e53..96e8f36 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod components; mod route; mod views; mod workspace; +mod workspace_list; use dioxus::prelude::*; diff --git a/src/workspace_list.rs b/src/workspace_list.rs new file mode 100644 index 0000000..8dc4464 --- /dev/null +++ b/src/workspace_list.rs @@ -0,0 +1,130 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io; +use std::path::PathBuf; + +/// Verzeichnisname der App. Überall kleingeschrieben — unter Linux ist das +/// Konvention, unter Windows in `%LOCALAPPDATA%` unauffällig. Eine Crate nur +/// für diese Normalisierung (`directories`) wäre der teurere Weg gewesen. +const APP_DIR: &str = "dionotes"; +const FILE_NAME: &str = "workspaces.ron"; + +/// Die anwendungsweite Liste bekannter Workspaces. +/// +/// Sie enthält nur Pfade: Der Anzeigename ist der Ordnername und wäre als +/// eigenes Feld nach dem ersten Umbenennen falsch. Auch keinen Zeitstempel — +/// die **Reihenfolge ist die Sortierung**, zuletzt geöffnet steht vorn. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct WorkspaceList { + workspaces: Vec, +} + +impl WorkspaceList { + pub fn entries(&self) -> &[PathBuf] { + &self.workspaces + } + + /// Hebt einen Workspace an die Spitze — beim Öffnen wie beim Hinzufügen. + /// Weil vorher gefiltert wird, kann derselbe Pfad nicht doppelt vorkommen. + pub fn promote(&mut self, path: PathBuf) { + self.workspaces.retain(|known| known != &path); + self.workspaces.insert(0, path); + } + + /// Wo die Liste liegt. + /// + /// Ein Ausdruck für alle Plattformen: `state_dir()` liefert nur unter Linux + /// `Some` — dort führt die XDG-Spec „recently used files" ausdrücklich als + /// State, gehört also nach `$XDG_STATE_HOME` und nicht nach `~/.config`. + /// Windows und macOS fallen auf das lokale Konfigurationsverzeichnis durch; + /// unter Windows bewusst `%LOCALAPPDATA%` statt Roaming, weil absolute + /// Workspace-Pfade maschinenspezifisch sind. + pub fn path() -> Option { + let base = dirs::state_dir().or_else(dirs::config_local_dir)?; + Some(base.join(APP_DIR).join(FILE_NAME)) + } + + /// Lesen. Eine fehlende oder unlesbare Datei ergibt eine leere Liste statt + /// eines Fehlers: Sie enthält nichts Unwiederbringliches — der Nutzer wählt + /// seine Ordner einmal neu, seine Notizen sind nicht betroffen. Deshalb + /// braucht die Datei auch keine Schema-Version. + pub fn load() -> Self { + Self::path() + .and_then(|path| fs::read_to_string(path).ok()) + .map(|text| Self::parse(&text)) + .unwrap_or_default() + } + + pub fn save(&self) -> io::Result<()> { + let path = Self::path().ok_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, "kein Konfigurationsverzeichnis") + })?; + // Das Basisverzeichnis existiert garantiert, das der App nicht. + if let Some(dir) = path.parent() { + fs::create_dir_all(dir)?; + } + fs::write(path, self.to_ron()?) + } + + fn parse(text: &str) -> Self { + ron::from_str(text).unwrap_or_default() + } + + fn to_ron(&self) -> io::Result { + ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default()) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) + } +} + +#[cfg(test)] +mod tests { + use super::WorkspaceList; + use std::path::PathBuf; + + fn list_of(paths: &[&str]) -> WorkspaceList { + let mut list = WorkspaceList::default(); + // rückwärts, damit die Reihenfolge am Ende der Eingabe entspricht + for p in paths.iter().rev() { + list.promote(PathBuf::from(p)); + } + list + } + + #[test] + fn promote_puts_new_entries_first() { + let mut list = list_of(&["a", "b"]); + list.promote(PathBuf::from("c")); + assert_eq!(list.entries(), [PathBuf::from("c"), PathBuf::from("a"), PathBuf::from("b")]); + } + + #[test] + fn promote_moves_known_entries_without_duplicating() { + let mut list = list_of(&["a", "b", "c"]); + list.promote(PathBuf::from("c")); + assert_eq!(list.entries(), [PathBuf::from("c"), PathBuf::from("a"), PathBuf::from("b")]); + } + + #[test] + fn survives_a_ron_roundtrip_with_windows_paths() { + let list = list_of(&[r"C:\Users\marvin\Notizen", r"D:\Projekte\Test"]); + let text = list.to_ron().expect("serialisierbar"); + assert_eq!(WorkspaceList::parse(&text), list); + } + + #[test] + fn path_lands_in_the_app_directory() { + let path = WorkspaceList::path().expect("Konfigurationsverzeichnis vorhanden"); + assert!( + path.ends_with("dionotes/workspaces.ron"), + "unerwarteter Ablageort: {}", + path.display() + ); + println!("Ablageort auf dieser Plattform: {}", path.display()); + } + + #[test] + fn unreadable_content_yields_an_empty_list() { + assert_eq!(WorkspaceList::parse("kein RON, nur Text"), WorkspaceList::default()); + assert_eq!(WorkspaceList::parse(""), WorkspaceList::default()); + } +} -- 2.49.1 From 276bc67dbef5a7162403a2ea722182c864f9f540 Mon Sep 17 00:00:00 2001 From: MDietermann Date: Fri, 28 Aug 2026 09:56:33 +0200 Subject: [PATCH 2/2] feat: Ordner-Dialog und Anlegen-Flow Setzt die Entscheidungen aus #2, #4 und #6 um: Der Knopf "Workspace hinzufuegen..." oeffnet den nativen Ordner-Dialog, legt den Marker an und uebernimmt den Workspace in Context, Liste und Editor. Der Marker ist .dionotes/workspace.ron mit genau (schema_version: 1). Er wird additiv angelegt, vorhandene Dateien bleiben unberuehrt - ein Test haelt das fest. Ein beschaedigter Marker fuehrt dazu, dass der Workspace nicht geoeffnet und der Marker nicht neu geschrieben wird; auch das ist getestet, denn sobald dort ein Passwort-Hash liegt, waere stillschweigendes Neuanlegen ein Weg, den Schutz auszuhebeln. OpenError unterscheidet die drei Zustaende aus #6 - nicht erreichbar, kein Workspace mehr, beschaedigt - plus den Fall eines zu neuen Schemas. Die Display-Impl liefert genau die Formulierungen, mit denen spaeter die Listeneintraege markiert werden. Der Flow folgt der Regel aus #6: Ein abgebrochener Dialog ist kein Fehler und erzeugt keine Meldung. Ein Ordner, der bereits Workspace ist, wird geoeffnet statt abgelehnt - derselbe Knopf nimmt neue und wiedergefundene Workspaces auf, was seine Beschriftung erst rechtfertigt. Ein nicht leerer Ordner loest eine Rueckfrage aus, die ausdruecklich sagt, dass nichts Vorhandenes veraendert wird; das ist beim Import der Normalfall, keine Ausnahme. rfd wird mit denselben Features eingebunden, die dioxus-desktop ohnehin zieht (xdg-portal statt default). Mit den Default-Features waeren neun Wayland-Pakete dazugekommen, die das Portal nicht braucht - so kostet die explizite Abhaengigkeit keinen einzigen zusaetzlichen Eintrag. --- Cargo.lock | 1 + Cargo.toml | 4 + assets/css/components.css | 50 ++++++ src/views/workspace_selector/selector.rs | 114 +++++++++++++- src/workspace.rs | 192 ++++++++++++++++++++++- 5 files changed, 353 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3827991..0de6385 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2330,6 +2330,7 @@ dependencies = [ "dioxus-code", "dioxus-code-editor", "dirs", + "rfd", "ron", "serde", "strum", diff --git a/Cargo.toml b/Cargo.toml index a70fd54..6400aa0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,10 @@ dioxus = { version = "0.7.1", features = ["router"] } dioxus-code = "0.1.2" dioxus-code-editor = { version = "0.1.2", features = ["all-languages"] } dirs = "6.0.0" +# Dieselbe Konfiguration, die dioxus-desktop ohnehin zieht. Mit den +# Default-Features kaemen neun Wayland-Pakete dazu, die xdg-portal nicht +# braucht — der Dialog laeuft unter Linux ueber das Portal. +rfd = { version = "0.17.2", default-features = false, features = ["xdg-portal"] } ron = "0.12.2" serde = { version = "1.0.229", features = ["derive"] } strum = { version = "0.28.0", features = ["derive"] } diff --git a/assets/css/components.css b/assets/css/components.css index e935786..6dba562 100644 --- a/assets/css/components.css +++ b/assets/css/components.css @@ -167,6 +167,56 @@ font-weight: 600; } +/* --- Dialog -------------------------------------------------------------------- + Für Unterbrechungen einer laufenden Aktion. Zustände gehören dagegen inline + an ihren Ort, nicht in ein zweites Fenster. + -------------------------------------------------------------------------- */ + +.dialog-scrim { + position: fixed; + inset: 0; + display: grid; + place-items: center; + padding: var(--dn-space-4); + background: rgb(0 0 0 / 0.55); + z-index: 50; +} + +.dialog { + width: min(28rem, 100%); + display: flex; + flex-direction: column; + gap: var(--dn-space-4); + padding: var(--dn-space-5); + border: 1px solid var(--dn-border-strong); + border-radius: var(--dn-radius-lg); + background: var(--dn-bg-sunken); + box-shadow: var(--dn-shadow); +} + +.dialog h2 { + font-size: var(--dn-text-md); + font-weight: 600; +} + +.dialog p { + color: var(--dn-fg-muted); + line-height: var(--dn-leading-normal); +} + +.dialog code { + font-family: var(--dn-font-code); + font-size: var(--dn-text-sm); + color: var(--dn-fg); + overflow-wrap: anywhere; +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: var(--dn-space-2); +} + /* --- Utilities ---------------------------------------------------------------- */ .visually-hidden { diff --git a/src/views/workspace_selector/selector.rs b/src/views/workspace_selector/selector.rs index e7b48ad..b3c94a0 100644 --- a/src/views/workspace_selector/selector.rs +++ b/src/views/workspace_selector/selector.rs @@ -1,24 +1,126 @@ use dioxus::prelude::*; +use std::path::PathBuf; + +use crate::route::Route; +use crate::workspace::{dir_is_empty, Workspace}; +use crate::workspace_list::WorkspaceList; /// Der Bildschirm vor dem Editor: einen bekannten Workspace öffnen oder einen /// hinzufügen. Eine einzige Route, keine Reiter — die Workspace-Liste führt, /// das Hinzufügen ist Nebenaktion. #[component] pub fn Selector() -> Element { + let mut workspace = use_context::>>(); + let mut list = use_signal(WorkspaceList::load); + let navigator = use_navigator(); + + // Ordner, der auf die Bestätigung „ist nicht leer" wartet. + let mut pending = use_signal(|| None::); + let mut error = use_signal(|| None::); + + // Übernimmt einen Workspace: Liste voranstellen und sichern, in den + // Context legen, in den Editor wechseln. + let mut accept = move |opened: Workspace| { + list.write().promote(opened.root.clone()); + if let Err(err) = list.read().save() { + // Kein Abbruch: Der Workspace ist offen, nur das Merken schlug fehl. + error.set(Some(format!("Der Workspace ist offen, aber die Liste ließ sich nicht speichern: {err}"))); + } + workspace.set(Some(opened)); + navigator.push(Route::Home {}); + }; + + // Wird sowohl nach dem Ordner-Dialog als auch nach der Rückfrage gebraucht. + let mut create_at = move |root: PathBuf| match Workspace::create(root) { + Ok(opened) => accept(opened), + Err(err) => error.set(Some(format!("Anlegen fehlgeschlagen: {err}"))), + }; + + let choose = move |_| async move { + // Abbruch liefert None und ist kein Fehler — keine Meldung. + let Some(handle) = rfd::AsyncFileDialog::new().pick_folder().await else { + return; + }; + let root = handle.path().to_path_buf(); + error.set(None); + + // Ein bereits vorhandener Workspace wird geöffnet, nicht abgelehnt: + // derselbe Knopf nimmt neue und wiedergefundene Workspaces auf. + if Workspace::has_marker(&root) { + match Workspace::open(root) { + Ok(opened) => accept(opened), + // Die Display-Impl benennt den Zustand bereits: „beschädigt", + // „kein Workspace mehr", „nicht erreichbar". + Err(err) => error.set(Some(format!("Öffnen fehlgeschlagen: {err}"))), + } + return; + } + + match dir_is_empty(&root) { + Ok(true) => create_at(root), + // Nicht leer ist der Normalfall beim Import — deshalb eine + // Rückfrage, keine Ablehnung. + Ok(false) => pending.set(Some(root)), + Err(err) => error.set(Some(format!("Ordner nicht lesbar: {err}"))), + } + }; + rsx! { div { class: "workspace-selector-layout", div { class: "workspace-selector-view", - // TODO: Workspace-Liste aus workspaces.ron laden und darstellen - // (Ordnername, Pfad darunter, Statusabzeichen). Bis dahin nur - // der leere Zustand. + // TODO: Workspace-Liste darstellen (Ordnername, Pfad, Statusabzeichen) + // statt des leeren Zustands, sobald Einträge vorhanden sind. div { class: "empty-state", span { class: "empty-state-title", "Noch kein Workspace" } span { "Der erste Workspace ist ein Ordner deiner Wahl." } - // TODO: Nativen Ordner-Dialog anbinden, Marker anlegen und - // den Workspace in den Context schreiben. - button { class: "btn btn-primary", disabled: true, + button { class: "btn btn-primary", onclick: choose, "Workspace hinzufügen…" } + if let Some(message) = error() { + span { class: "field-error", "{message}" } + } + } + } + } + + if let Some(root) = pending() { + NotEmptyDialog { + root: root.clone(), + onconfirm: move |root| { + pending.set(None); + create_at(root); + }, + oncancel: move |_| pending.set(None), + } + } + } +} + +/// Rückfrage vor dem Anlegen in einem Ordner, der bereits Dateien enthält. +/// Sie soll informieren, nicht beunruhigen — deshalb steht ausdrücklich da, +/// dass nichts Vorhandenes verändert wird. +#[component] +fn NotEmptyDialog(root: PathBuf, onconfirm: EventHandler, oncancel: EventHandler<()>) -> Element { + let shown = root.display().to_string(); + let confirmed = root.clone(); + + rsx! { + div { class: "dialog-scrim", + div { class: "dialog", + h2 { "Ordner ist nicht leer" } + p { + code { "{shown}" } + " enthält bereits Dateien. DioNotes legt darin nur den Ordner " + code { ".dionotes" } + " an und verändert nichts Vorhandenes." + } + div { class: "dialog-actions", + button { class: "btn btn-ghost", onclick: move |_| oncancel.call(()), "Abbrechen" } + button { + class: "btn btn-primary", + onclick: move |_| onconfirm.call(confirmed.clone()), + "Workspace anlegen" + } } } } diff --git a/src/workspace.rs b/src/workspace.rs index 15e8217..783f145 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -1,4 +1,26 @@ -use std::path::PathBuf; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +const MARKER_DIR: &str = ".dionotes"; +const MARKER_FILE: &str = "workspace.ron"; + +/// Aktuelle Fassung des Marker-Schemas. Erhöhen, sobald Felder dazukommen, +/// die eine ältere App nicht bloß ignorieren kann. +const SCHEMA_VERSION: u32 = 1; + +/// Inhalt von `.dionotes/workspace.ron`. +/// +/// Heute genau ein Feld. Kein `created_at` — das Erstellungsdatum kennt das +/// Dateisystem, es hier zu duplizieren schafft eine zweite Wahrheit, die beim +/// Kopieren des Ordners auseinanderläuft. Weitere Felder entstehen mit dem +/// Feature, das sie braucht; `#[serde(default)]` macht das bruchfrei. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct Marker { + schema_version: u32, +} /// Ein geöffneter Workspace — der Ordner auf der Platte, in dem die Notizen liegen. /// @@ -10,6 +32,50 @@ pub struct Workspace { pub root: PathBuf, } +/// Warum ein Ordner sich nicht als Workspace öffnen ließ. +/// +/// Die ersten drei Varianten sind genau die Zustände, mit denen ein +/// Listeneintrag markiert wird — sie führen zu verschiedenen Handlungen: +/// Laufwerk einhängen, Ordner wiederherstellen, Datei reparieren. +#[derive(Debug)] +pub enum OpenError { + /// Der Ordner existiert nicht. Meist ein abgezogener Stick oder ein nicht + /// eingehängtes Netzlaufwerk — deshalb wird der Eintrag nie automatisch + /// aus der Liste entfernt. + Unreachable, + /// Der Ordner ist da, trägt aber keinen Marker. + NotAWorkspace, + /// Marker vorhanden, aber nicht lesbar. Der Workspace wird **nicht** + /// geöffnet und der Marker **nicht** neu angelegt: Sobald dort ein + /// Passwort-Hash liegt, wäre stillschweigendes Neuanlegen ein Weg, den + /// Schutz auszuhebeln. + Damaged, + /// Der Marker stammt aus einer neueren Fassung der App. + TooNew { found: u32 }, + Io(io::Error), +} + +impl fmt::Display for OpenError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Unreachable => write!(f, "nicht erreichbar"), + Self::NotAWorkspace => write!(f, "kein Workspace mehr"), + Self::Damaged => write!(f, "beschädigt"), + Self::TooNew { found } => write!( + f, + "mit einer neueren Version angelegt (Schema {found}, unterstützt wird {SCHEMA_VERSION})" + ), + Self::Io(err) => write!(f, "{err}"), + } + } +} + +impl From for OpenError { + fn from(err: io::Error) -> Self { + Self::Io(err) + } +} + impl Workspace { /// Anzeigename: der Ordnername. Bewusst abgeleitet statt gespeichert — /// ein eigenes Namensfeld wäre nach dem ersten Umbenennen des Ordners falsch. @@ -19,16 +85,138 @@ impl Workspace { .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| self.root.to_string_lossy().into_owned()) } + + fn marker_path(root: &Path) -> PathBuf { + root.join(MARKER_DIR).join(MARKER_FILE) + } + + /// Ob dieser Ordner einen Marker trägt — die Frage „ist das hier überhaupt + /// ein Workspace?". Sagt nichts darüber, ob der Marker lesbar ist. + pub fn has_marker(root: &Path) -> bool { + Self::marker_path(root).is_file() + } + + /// Einen bestehenden Workspace öffnen. + pub fn open(root: PathBuf) -> Result { + if !root.is_dir() { + return Err(OpenError::Unreachable); + } + let marker_path = Self::marker_path(&root); + if !marker_path.is_file() { + return Err(OpenError::NotAWorkspace); + } + + let text = fs::read_to_string(&marker_path).map_err(|_| OpenError::Damaged)?; + let marker: Marker = ron::from_str(&text).map_err(|_| OpenError::Damaged)?; + if marker.schema_version > SCHEMA_VERSION { + return Err(OpenError::TooNew { found: marker.schema_version }); + } + + Ok(Self { root }) + } + + /// Einen Ordner zum Workspace machen: `.dionotes/workspace.ron` anlegen. + /// + /// Vorhandene Dateien im Ordner bleiben unberührt — der Marker ist additiv. + pub fn create(root: PathBuf) -> Result { + if !root.is_dir() { + return Err(OpenError::Unreachable); + } + let marker_path = Self::marker_path(&root); + if let Some(dir) = marker_path.parent() { + fs::create_dir_all(dir)?; + } + let marker = Marker { schema_version: SCHEMA_VERSION }; + let text = ron::ser::to_string_pretty(&marker, ron::ser::PrettyConfig::default()) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + fs::write(&marker_path, text)?; + Ok(Self { root }) + } +} + +/// Ob ein Ordner leer ist — Grundlage für die Rückfrage vor dem Anlegen. +pub fn dir_is_empty(path: &Path) -> io::Result { + Ok(fs::read_dir(path)?.next().is_none()) } #[cfg(test)] mod tests { - use super::Workspace; + use super::{dir_is_empty, OpenError, Workspace}; + use std::fs; use std::path::PathBuf; + /// Frisches Testverzeichnis. Der Testname macht es eindeutig, deshalb + /// braucht es keine Zufallszahl und keine tempfile-Crate. + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("dionotes-test-{name}")); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("Testverzeichnis anlegbar"); + dir + } + #[test] fn name_is_the_folder_name() { let ws = Workspace { root: PathBuf::from("C:/Users/marvin/Notizen") }; assert_eq!(ws.name(), "Notizen"); } + + #[test] + fn create_then_open_roundtrip() { + let dir = scratch("create-open"); + let created = Workspace::create(dir.clone()).expect("anlegbar"); + assert!(Workspace::has_marker(&dir)); + assert_eq!(Workspace::open(dir.clone()).expect("oeffenbar"), created); + } + + #[test] + fn create_leaves_existing_files_alone() { + let dir = scratch("create-keeps-files"); + fs::write(dir.join("notiz.md"), "# Hallo").unwrap(); + Workspace::create(dir.clone()).expect("anlegbar"); + assert_eq!(fs::read_to_string(dir.join("notiz.md")).unwrap(), "# Hallo"); + } + + #[test] + fn missing_folder_is_unreachable() { + let dir = scratch("gone").join("weg"); + assert!(matches!(Workspace::open(dir), Err(OpenError::Unreachable))); + } + + #[test] + fn folder_without_marker_is_not_a_workspace() { + let dir = scratch("no-marker"); + assert!(matches!(Workspace::open(dir), Err(OpenError::NotAWorkspace))); + } + + #[test] + fn unreadable_marker_is_damaged_and_not_recreated() { + let dir = scratch("damaged"); + Workspace::create(dir.clone()).expect("anlegbar"); + let marker = dir.join(".dionotes").join("workspace.ron"); + fs::write(&marker, "kaputt").unwrap(); + + assert!(matches!(Workspace::open(dir), Err(OpenError::Damaged))); + // Der Marker wurde nicht stillschweigend ersetzt. + assert_eq!(fs::read_to_string(&marker).unwrap(), "kaputt"); + } + + #[test] + fn newer_schema_is_refused_with_the_version() { + let dir = scratch("too-new"); + Workspace::create(dir.clone()).expect("anlegbar"); + fs::write(dir.join(".dionotes").join("workspace.ron"), "(schema_version: 99)").unwrap(); + + match Workspace::open(dir) { + Err(OpenError::TooNew { found }) => assert_eq!(found, 99), + other => panic!("erwartet TooNew, war {other:?}"), + } + } + + #[test] + fn empty_folder_is_recognised() { + let dir = scratch("emptiness"); + assert!(dir_is_empty(&dir).unwrap()); + fs::write(dir.join("etwas.txt"), "x").unwrap(); + assert!(!dir_is_empty(&dir).unwrap()); + } } -- 2.49.1