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. /// /// Heute genau ein Feld. Der Typ existiert trotzdem als eigener Name, weil /// `CONTEXT.md` „Workspace" als Domänenbegriff führt und die Einstellungen aus /// `.dionotes/workspace.ron` hier andocken werden. #[derive(Debug, Clone, PartialEq)] 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. pub fn name(&self) -> String { self.root .file_name() .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::{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()); } }