Skip to main content

mach/
store.rs

1//! SQLite-backed persistence for tasks, categories, and settings.
2//!
3//! A [`Store`] is an explicit instance: callers can open independent data
4//! directories in one process. Writes use `BEGIN IMMEDIATE`, validate a fresh
5//! snapshot, and commit tasks/categories/settings/attachment metadata plus a
6//! monotonic revision in one transaction. Image bytes are immutable,
7//! content-addressed files beside the database. Legacy JSON is imported once
8//! and left untouched.
9
10use std::collections::{HashMap, HashSet};
11use std::fs;
12use std::io::{Read, Write};
13use std::path::{Path, PathBuf};
14use std::time::Duration;
15
16use chrono::{Local, NaiveDateTime};
17use image::ImageFormat;
18use rusqlite::limits::Limit;
19use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params};
20use serde::Deserialize;
21use serde::de::DeserializeOwned;
22use sha2::{Digest, Sha256};
23use unicode_normalization::UnicodeNormalization;
24use unicode_segmentation::UnicodeSegmentation;
25
26use crate::due;
27use crate::model::{
28    Block, Category, MAX_BODY_LINES, MAX_CATEGORY_COUNT, MAX_CATEGORY_DESC_LINE_LEN,
29    MAX_CATEGORY_DESC_LINES, MAX_CATEGORY_NAME_LEN, MAX_IMPORTANCE, MAX_NOTES_LINE_LEN,
30    MAX_TASK_COUNT, MAX_TITLE_LEN, SCHEMA_VERSION, Task, caseless_key, text_byte_limit,
31};
32use crate::settings::{DATE_FORMATS, PREVIEW_POSITIONS, SORTS, Settings, THEMES};
33
34const DATABASE_FILE: &str = "mach.db";
35const DATABASE_SCHEMA_VERSION: i64 = 1;
36const LEGACY_MIGRATION_KEY: &str = "legacy_json_migrated";
37const BUSY_TIMEOUT: Duration = Duration::from_secs(10);
38const ID_MAX_BYTES: usize = 128;
39const DUE_MAX_BYTES: usize = 128;
40const CREATED_MAX_BYTES: usize = 64;
41const SETTINGS_VALUE_MAX_BYTES: usize = 128;
42const MAX_LEGACY_JSON_BYTES: u64 = 128 * 1024 * 1024;
43const MAX_SQLITE_VALUE_BYTES: i32 = 8 * 1024 * 1024;
44const MAX_ATTACHMENT_BYTES: u64 = 128 * 1024 * 1024;
45const ATTACHMENT_ID_LEN: usize = 64;
46
47#[derive(Debug)]
48pub enum StoreError {
49    Io {
50        operation: &'static str,
51        path: PathBuf,
52        source: std::io::Error,
53    },
54    Json {
55        path: PathBuf,
56        source: serde_json::Error,
57    },
58    Database(rusqlite::Error),
59    UnsupportedLegacySchema {
60        path: PathBuf,
61        found: u32,
62        expected: u32,
63    },
64    UnsupportedDatabaseSchema {
65        path: PathBuf,
66        found: i64,
67        expected: i64,
68    },
69    Conflict {
70        expected: u64,
71        actual: u64,
72    },
73    StaleEntity {
74        entity: &'static str,
75        id: String,
76    },
77    NotFound {
78        entity: &'static str,
79        query: String,
80    },
81    Ambiguous {
82        entity: &'static str,
83        query: String,
84        matches: Vec<String>,
85    },
86    Validation(String),
87    Corrupt(String),
88}
89
90impl StoreError {
91    pub fn validation(message: impl Into<String>) -> Self {
92        Self::Validation(message.into())
93    }
94
95    fn io(operation: &'static str, path: &Path, source: std::io::Error) -> Self {
96        Self::Io {
97            operation,
98            path: path.to_path_buf(),
99            source,
100        }
101    }
102}
103
104impl std::fmt::Display for StoreError {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        match self {
107            Self::Io {
108                operation,
109                path,
110                source,
111            } => write!(f, "could not {operation} {}: {source}", path.display()),
112            Self::Json { path, source } => {
113                write!(f, "could not parse {}: {source}", path.display())
114            }
115            Self::Database(source) => write!(f, "database error: {source}"),
116            Self::UnsupportedLegacySchema {
117                path,
118                found,
119                expected,
120            } => write!(
121                f,
122                "{} uses unsupported schema {found} (expected {expected})",
123                path.display()
124            ),
125            Self::UnsupportedDatabaseSchema {
126                path,
127                found,
128                expected,
129            } => write!(
130                f,
131                "{} uses unsupported database schema {found} (expected {expected})",
132                path.display()
133            ),
134            Self::Conflict { expected, actual } => write!(
135                f,
136                "store changed since it was loaded (expected revision {expected}, found {actual})"
137            ),
138            Self::StaleEntity { entity, id } => {
139                write!(f, "{entity} {id:?} changed since it was loaded")
140            }
141            Self::NotFound { entity, query } => {
142                write!(f, "no {entity} matching {query:?}")
143            }
144            Self::Ambiguous {
145                entity,
146                query,
147                matches,
148            } => write!(
149                f,
150                "ambiguous {entity} {query:?}; matches: {}",
151                matches.join(", ")
152            ),
153            Self::Validation(message) => write!(f, "invalid data: {message}"),
154            Self::Corrupt(message) => write!(f, "corrupt database: {message}"),
155        }
156    }
157}
158
159impl std::error::Error for StoreError {
160    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
161        match self {
162            Self::Io { source, .. } => Some(source),
163            Self::Json { source, .. } => Some(source),
164            Self::Database(source) => Some(source),
165            _ => None,
166        }
167    }
168}
169
170impl From<rusqlite::Error> for StoreError {
171    fn from(value: rusqlite::Error) -> Self {
172        Self::Database(value)
173    }
174}
175
176#[derive(Debug, Clone, Default)]
177pub struct StoreData {
178    pub revision: u64,
179    pub categories: Vec<Category>,
180    pub tasks: Vec<Task>,
181    pub settings: Settings,
182    pub(crate) attachments: Vec<Attachment>,
183}
184
185/// Immutable metadata for one content-addressed image owned by this store.
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub struct Attachment {
188    pub id: String,
189    pub sha256: String,
190    pub media_type: String,
191    pub byte_len: u64,
192    pub storage_name: String,
193}
194
195#[derive(Debug, Clone, Default)]
196pub struct TaskPatch {
197    pub title: Option<String>,
198    pub body: Option<Vec<Block>>,
199    pub due: Option<String>,
200    pub done: Option<bool>,
201    pub importance: Option<u8>,
202    /// `None` leaves the category unchanged; `Some(None)` clears it.
203    pub category_id: Option<Option<String>>,
204}
205
206#[derive(Debug, Clone, Default)]
207pub struct CategoryPatch {
208    pub name: Option<String>,
209    pub description: Option<String>,
210}
211
212#[derive(Debug, Clone, PartialEq, Eq)]
213pub enum PurgeScope {
214    All,
215    Category(String),
216    Uncategorized,
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220pub enum RelativePosition {
221    Before,
222    After,
223}
224
225impl StoreData {
226    pub fn attachments(&self) -> &[Attachment] {
227        &self.attachments
228    }
229
230    /// Resolve a task by full id or unique id prefix.
231    pub fn resolve_task_id(&self, query: &str) -> Result<String, StoreError> {
232        let query = query.trim();
233        if query.is_empty() {
234            return Err(StoreError::validation("task id cannot be empty"));
235        }
236        validate_byte_limit(query, ID_MAX_BYTES, "task id query")?;
237        if let Some(task) = self.tasks.iter().find(|task| task.id == query) {
238            return Ok(task.id.clone());
239        }
240        let matches: Vec<_> = self
241            .tasks
242            .iter()
243            .filter(|task| task.id.starts_with(query))
244            .map(|task| task.id.clone())
245            .collect();
246        match matches.as_slice() {
247            [id] => Ok(id.clone()),
248            [] => Err(StoreError::NotFound {
249                entity: "task",
250                query: query.to_string(),
251            }),
252            _ => Err(StoreError::Ambiguous {
253                entity: "task id",
254                query: query.to_string(),
255                matches,
256            }),
257        }
258    }
259
260    /// Resolve a category by id, Unicode-caseless name, or unique name prefix.
261    pub fn resolve_category_id(&self, query: &str) -> Result<String, StoreError> {
262        let query = query.trim();
263        if query.is_empty() {
264            return Err(StoreError::validation("category name cannot be empty"));
265        }
266        if let Some(category) = self.categories.iter().find(|category| category.id == query) {
267            return Ok(category.id.clone());
268        }
269        validate_byte_limit(
270            query,
271            text_byte_limit(MAX_CATEGORY_NAME_LEN),
272            "category query",
273        )?;
274        let folded = category_name_key(query);
275        if let Some(category) = self
276            .categories
277            .iter()
278            .find(|category| category_name_key(&category.name) == folded)
279        {
280            return Ok(category.id.clone());
281        }
282        let matches: Vec<_> = self
283            .categories
284            .iter()
285            .filter(|category| category_name_has_prefix(&category.name, &folded))
286            .collect();
287        match matches.as_slice() {
288            [category] => Ok(category.id.clone()),
289            [] => Err(StoreError::NotFound {
290                entity: "category",
291                query: query.to_string(),
292            }),
293            _ => Err(StoreError::Ambiguous {
294                entity: "category",
295                query: query.to_string(),
296                matches: matches
297                    .into_iter()
298                    .map(|category| category.name.clone())
299                    .collect(),
300            }),
301        }
302    }
303
304    pub fn task(&self, id: &str) -> Result<&Task, StoreError> {
305        self.tasks
306            .iter()
307            .find(|task| task.id == id)
308            .ok_or_else(|| StoreError::NotFound {
309                entity: "task",
310                query: id.to_string(),
311            })
312    }
313
314    pub fn category(&self, id: &str) -> Result<&Category, StoreError> {
315        self.categories
316            .iter()
317            .find(|category| category.id == id)
318            .ok_or_else(|| StoreError::NotFound {
319                entity: "category",
320                query: id.to_string(),
321            })
322    }
323
324    pub fn create_task(
325        &mut self,
326        title: impl Into<String>,
327        body: Vec<Block>,
328        due: impl Into<String>,
329        importance: u8,
330        category_id: Option<String>,
331    ) -> Result<Task, StoreError> {
332        if importance > MAX_IMPORTANCE {
333            return Err(StoreError::validation(format!(
334                "importance must be 0-{MAX_IMPORTANCE}"
335            )));
336        }
337        let title = title.into();
338        let due = due.into();
339        let mut task = Task::new(&title, importance, category_id, &due);
340        task.body = body;
341        self.insert_task(task)
342    }
343
344    pub fn insert_task(&mut self, task: Task) -> Result<Task, StoreError> {
345        let index = self.tasks.len();
346        self.tasks.push(task);
347        if let Err(error) = self.normalize_and_validate_new_write() {
348            self.tasks.truncate(index);
349            return Err(error);
350        }
351        Ok(self.tasks[index].clone())
352    }
353
354    pub fn edit_task(&mut self, id: &str, patch: TaskPatch) -> Result<Task, StoreError> {
355        let index = self
356            .tasks
357            .iter()
358            .position(|task| task.id == id)
359            .ok_or_else(|| StoreError::NotFound {
360                entity: "task",
361                query: id.to_string(),
362            })?;
363        let before = self.tasks[index].clone();
364        {
365            let task = &mut self.tasks[index];
366            if let Some(title) = patch.title {
367                task.title = title;
368            }
369            if let Some(body) = patch.body {
370                task.body = body;
371            }
372            if let Some(due) = patch.due {
373                task.due = due;
374            }
375            if let Some(done) = patch.done {
376                task.done = done;
377            }
378            if let Some(importance) = patch.importance {
379                task.importance = importance;
380            }
381            if let Some(category_id) = patch.category_id {
382                task.category_id = category_id;
383            }
384        }
385        if let Err(error) = self.normalize_and_validate_new_write() {
386            self.tasks[index] = before;
387            return Err(error);
388        }
389        Ok(self.tasks[index].clone())
390    }
391
392    /// Apply only the fields represented by `patch`, but fail if one of those
393    /// fields changed since `expected` was loaded. Unrelated concurrent edits
394    /// (for example toggling `done` while a title form is open) are preserved.
395    pub fn edit_task_if_unchanged(
396        &mut self,
397        expected: &Task,
398        patch: TaskPatch,
399    ) -> Result<Task, StoreError> {
400        let current = self
401            .tasks
402            .iter()
403            .find(|task| task.id == expected.id)
404            .ok_or_else(|| StoreError::StaleEntity {
405                entity: "task",
406                id: expected.id.clone(),
407            })?;
408        let stale = field_conflicts(patch.title.as_ref(), &current.title, &expected.title)
409            || field_conflicts(patch.body.as_ref(), &current.body, &expected.body)
410            || field_conflicts(patch.due.as_ref(), &current.due, &expected.due)
411            || field_conflicts(patch.done.as_ref(), &current.done, &expected.done)
412            || field_conflicts(
413                patch.importance.as_ref(),
414                &current.importance,
415                &expected.importance,
416            )
417            || field_conflicts(
418                patch.category_id.as_ref(),
419                &current.category_id,
420                &expected.category_id,
421            );
422        if stale {
423            return Err(StoreError::StaleEntity {
424                entity: "task",
425                id: expected.id.clone(),
426            });
427        }
428        self.edit_task(&expected.id, patch)
429    }
430
431    pub fn delete_task(&mut self, id: &str) -> Result<Task, StoreError> {
432        let index = self
433            .tasks
434            .iter()
435            .position(|task| task.id == id)
436            .ok_or_else(|| StoreError::NotFound {
437                entity: "task",
438                query: id.to_string(),
439            })?;
440        Ok(self.tasks.remove(index))
441    }
442
443    pub fn set_task_done(&mut self, id: &str, done: bool) -> Result<Task, StoreError> {
444        self.edit_task(
445            id,
446            TaskPatch {
447                done: Some(done),
448                ..TaskPatch::default()
449            },
450        )
451    }
452
453    pub fn toggle_task_done(&mut self, id: &str) -> Result<Task, StoreError> {
454        let done = !self.task(id)?.done;
455        self.set_task_done(id, done)
456    }
457
458    pub fn set_task_importance(&mut self, id: &str, importance: u8) -> Result<Task, StoreError> {
459        self.edit_task(
460            id,
461            TaskPatch {
462                importance: Some(importance),
463                ..TaskPatch::default()
464            },
465        )
466    }
467
468    pub fn set_task_category(
469        &mut self,
470        id: &str,
471        category_id: Option<String>,
472    ) -> Result<Task, StoreError> {
473        self.edit_task(
474            id,
475            TaskPatch {
476                category_id: Some(category_id),
477                ..TaskPatch::default()
478            },
479        )
480    }
481
482    pub fn move_task(&mut self, id: &str, target: usize) -> Result<(), StoreError> {
483        let index = self
484            .tasks
485            .iter()
486            .position(|task| task.id == id)
487            .ok_or_else(|| StoreError::NotFound {
488                entity: "task",
489                query: id.to_string(),
490            })?;
491        if target >= self.tasks.len() {
492            return Err(StoreError::validation(format!(
493                "task target index {target} is out of range"
494            )));
495        }
496        if self.tasks[index].category_id != self.tasks[target].category_id {
497            return Err(StoreError::validation(
498                "tasks can only be reordered within the same category",
499            ));
500        }
501        let task = self.tasks.remove(index);
502        self.tasks.insert(target, task);
503        Ok(())
504    }
505
506    pub fn move_task_relative(
507        &mut self,
508        id: &str,
509        target_id: &str,
510        position: RelativePosition,
511    ) -> Result<Task, StoreError> {
512        let id = id.to_string();
513        let target_id = target_id.to_string();
514        if id == target_id {
515            return Err(StoreError::validation(
516                "cannot move a task relative to itself",
517            ));
518        }
519        let index = self
520            .tasks
521            .iter()
522            .position(|task| task.id == id)
523            .ok_or_else(|| StoreError::NotFound {
524                entity: "task",
525                query: id.clone(),
526            })?;
527        let target_index = self
528            .tasks
529            .iter()
530            .position(|task| task.id == target_id)
531            .ok_or_else(|| StoreError::NotFound {
532                entity: "task",
533                query: target_id.clone(),
534            })?;
535        if self.tasks[index].category_id != self.tasks[target_index].category_id {
536            return Err(StoreError::validation(
537                "tasks can only be reordered within the same category",
538            ));
539        }
540        let task = self.tasks.remove(index);
541        let target_after_removal = self
542            .tasks
543            .iter()
544            .position(|candidate| candidate.id == target_id)
545            .ok_or_else(|| {
546                StoreError::Corrupt(format!("task {target_id:?} disappeared during reorder"))
547            })?;
548        let insertion = match position {
549            RelativePosition::Before => target_after_removal,
550            RelativePosition::After => target_after_removal + 1,
551        };
552        self.tasks.insert(insertion, task.clone());
553        Ok(task)
554    }
555
556    pub fn purge_completed(&mut self, scope: &PurgeScope) -> Result<Vec<Task>, StoreError> {
557        if let PurgeScope::Category(id) = scope {
558            self.category(id)?;
559        }
560        let mut removed = Vec::new();
561        self.tasks.retain(|task| {
562            let in_scope = match scope {
563                PurgeScope::All => true,
564                PurgeScope::Category(id) => task.category_id.as_deref() == Some(id),
565                PurgeScope::Uncategorized => task.category_id.is_none(),
566            };
567            if task.done && in_scope {
568                removed.push(task.clone());
569                false
570            } else {
571                true
572            }
573        });
574        Ok(removed)
575    }
576
577    /// Purge only the completed tasks captured by a confirmation prompt.
578    pub fn purge_completed_ids(&mut self, ids: &[String]) -> Result<Vec<Task>, StoreError> {
579        let ids: HashSet<_> = ids.iter().map(String::as_str).collect();
580        let mut removed = Vec::new();
581        self.tasks.retain(|task| {
582            if task.done && ids.contains(task.id.as_str()) {
583                removed.push(task.clone());
584                false
585            } else {
586                true
587            }
588        });
589        Ok(removed)
590    }
591
592    pub fn create_category(
593        &mut self,
594        name: impl Into<String>,
595        description: impl Into<String>,
596    ) -> Result<Category, StoreError> {
597        let name = name.into();
598        let mut category = Category::new(&name);
599        category.description = description.into();
600        self.insert_category(category)
601    }
602
603    pub fn insert_category(&mut self, category: Category) -> Result<Category, StoreError> {
604        let index = self.categories.len();
605        self.categories.push(category);
606        if let Err(error) = self.normalize_and_validate_new_write() {
607            self.categories.truncate(index);
608            return Err(error);
609        }
610        Ok(self.categories[index].clone())
611    }
612
613    pub fn edit_category(
614        &mut self,
615        id: &str,
616        patch: CategoryPatch,
617    ) -> Result<Category, StoreError> {
618        let index = self
619            .categories
620            .iter()
621            .position(|category| category.id == id)
622            .ok_or_else(|| StoreError::NotFound {
623                entity: "category",
624                query: id.to_string(),
625            })?;
626        let before = self.categories[index].clone();
627        {
628            let category = &mut self.categories[index];
629            if let Some(name) = patch.name {
630                category.name = name;
631            }
632            if let Some(description) = patch.description {
633                category.description = description;
634            }
635        }
636        if let Err(error) = self.normalize_and_validate_new_write() {
637            self.categories[index] = before;
638            return Err(error);
639        }
640        Ok(self.categories[index].clone())
641    }
642
643    pub fn edit_category_if_unchanged(
644        &mut self,
645        expected: &Category,
646        patch: CategoryPatch,
647    ) -> Result<Category, StoreError> {
648        let current = self
649            .categories
650            .iter()
651            .find(|category| category.id == expected.id)
652            .ok_or_else(|| StoreError::StaleEntity {
653                entity: "category",
654                id: expected.id.clone(),
655            })?;
656        let stale = field_conflicts(patch.name.as_ref(), &current.name, &expected.name)
657            || field_conflicts(
658                patch.description.as_ref(),
659                &current.description,
660                &expected.description,
661            );
662        if stale {
663            return Err(StoreError::StaleEntity {
664                entity: "category",
665                id: expected.id.clone(),
666            });
667        }
668        self.edit_category(&expected.id, patch)
669    }
670
671    /// Delete a category while preserving its tasks as uncategorized.
672    pub fn delete_category(&mut self, id: &str) -> Result<Category, StoreError> {
673        let index = self
674            .categories
675            .iter()
676            .position(|category| category.id == id)
677            .ok_or_else(|| StoreError::NotFound {
678                entity: "category",
679                query: id.to_string(),
680            })?;
681        let category = self.categories.remove(index);
682        for task in &mut self.tasks {
683            if task.category_id.as_deref() == Some(id) {
684                task.category_id = None;
685            }
686        }
687        Ok(category)
688    }
689
690    pub fn move_category(&mut self, id: &str, target: usize) -> Result<(), StoreError> {
691        let index = self
692            .categories
693            .iter()
694            .position(|category| category.id == id)
695            .ok_or_else(|| StoreError::NotFound {
696                entity: "category",
697                query: id.to_string(),
698            })?;
699        if target >= self.categories.len() {
700            return Err(StoreError::validation(format!(
701                "category target index {target} is out of range"
702            )));
703        }
704        let category = self.categories.remove(index);
705        self.categories.insert(target, category);
706        Ok(())
707    }
708
709    pub fn move_category_relative(
710        &mut self,
711        id: &str,
712        target_id: &str,
713        position: RelativePosition,
714    ) -> Result<Category, StoreError> {
715        if id == target_id {
716            return Err(StoreError::validation(
717                "cannot move a category relative to itself",
718            ));
719        }
720        let index = self
721            .categories
722            .iter()
723            .position(|category| category.id == id)
724            .ok_or_else(|| StoreError::NotFound {
725                entity: "category",
726                query: id.to_string(),
727            })?;
728        if !self
729            .categories
730            .iter()
731            .any(|category| category.id == target_id)
732        {
733            return Err(StoreError::NotFound {
734                entity: "category",
735                query: target_id.to_string(),
736            });
737        }
738        let category = self.categories.remove(index);
739        let target_after_removal = self
740            .categories
741            .iter()
742            .position(|candidate| candidate.id == target_id)
743            .ok_or_else(|| {
744                StoreError::Corrupt(format!("category {target_id:?} disappeared during reorder"))
745            })?;
746        let insertion = match position {
747            RelativePosition::Before => target_after_removal,
748            RelativePosition::After => target_after_removal + 1,
749        };
750        self.categories.insert(insertion, category.clone());
751        Ok(category)
752    }
753
754    pub fn replace_settings(&mut self, settings: Settings) -> Result<Settings, StoreError> {
755        let before = std::mem::replace(&mut self.settings, settings);
756        if let Err(error) = validate_settings(&self.settings) {
757            self.settings = before;
758            return Err(error);
759        }
760        Ok(self.settings.clone())
761    }
762
763    pub fn update_settings(
764        &mut self,
765        operation: impl FnOnce(&mut Settings),
766    ) -> Result<Settings, StoreError> {
767        let before = self.settings.clone();
768        operation(&mut self.settings);
769        if let Err(error) = validate_settings(&self.settings) {
770            self.settings = before;
771            return Err(error);
772        }
773        Ok(self.settings.clone())
774    }
775
776    fn normalize_and_validate_new_write(&mut self) -> Result<(), StoreError> {
777        normalize_and_validate(
778            self,
779            Local::now().naive_local(),
780            DueMode::NewWrite,
781            AttachmentMode::Draft,
782        )
783    }
784}
785
786/// A three-way field merge conflicts only when the remote and desired values
787/// both diverged from the captured base in different directions.
788fn field_conflicts<T: PartialEq>(desired: Option<&T>, current: &T, expected: &T) -> bool {
789    desired.is_some_and(|desired| current != expected && current != desired)
790}
791
792#[derive(Debug, Clone)]
793pub struct Paths {
794    pub dir: PathBuf,
795    pub database: PathBuf,
796    pub tasks: PathBuf,
797    pub categories: PathBuf,
798    pub settings: PathBuf,
799    pub images: PathBuf,
800}
801
802impl Paths {
803    fn new(dir: PathBuf) -> Self {
804        Self {
805            database: dir.join(DATABASE_FILE),
806            tasks: dir.join("tasks.json"),
807            categories: dir.join("categories.json"),
808            settings: dir.join("settings.json"),
809            images: dir.join("images"),
810            dir,
811        }
812    }
813}
814
815pub struct Store {
816    connection: Connection,
817    paths: Paths,
818    persistent_attachments: bool,
819}
820
821impl Store {
822    pub fn open(dir: impl AsRef<Path>) -> Result<Self, StoreError> {
823        let paths = Paths::new(expand_user(dir.as_ref().to_path_buf())?);
824        ensure_private_directory(&paths.dir)?;
825        prepare_private_database_file(&paths.database)?;
826        let mut connection = Connection::open(&paths.database)?;
827        set_private_file(&paths.database)?;
828        connection.busy_timeout(BUSY_TIMEOUT)?;
829        configure_resource_limits(&connection)?;
830        initialize_schema(&mut connection, &paths.database)?;
831        configure_connection(&connection)?;
832        quick_check(&connection)?;
833        let mut store = Self {
834            connection,
835            paths,
836            persistent_attachments: true,
837        };
838        store.migrate_legacy_json()?;
839        Ok(store)
840    }
841
842    /// Open an ephemeral store with no filesystem persistence.
843    ///
844    /// `data_dir` is only the logical base for relative image references. The
845    /// directory is not created or modified.
846    pub fn open_in_memory_with_paths(data_dir: impl AsRef<Path>) -> Result<Self, StoreError> {
847        let paths = Paths::new(expand_user(data_dir.as_ref().to_path_buf())?);
848        let mut connection = Connection::open_in_memory()?;
849        connection.busy_timeout(BUSY_TIMEOUT)?;
850        configure_resource_limits(&connection)?;
851        initialize_schema(&mut connection, Path::new(":memory:"))?;
852        configure_in_memory_connection(&connection)?;
853        quick_check(&connection)?;
854        connection.execute(
855            "INSERT INTO metadata(key, value) VALUES (?1, '1')",
856            [LEGACY_MIGRATION_KEY],
857        )?;
858        Ok(Self {
859            connection,
860            paths,
861            persistent_attachments: false,
862        })
863    }
864
865    pub fn open_default(explicit: Option<PathBuf>) -> Result<Self, StoreError> {
866        Self::open(resolve_data_dir(explicit)?)
867    }
868
869    pub fn paths(&self) -> &Paths {
870        &self.paths
871    }
872
873    pub fn data_dir(&self) -> &Path {
874        &self.paths.dir
875    }
876
877    pub fn images_dir(&self) -> &Path {
878        &self.paths.images
879    }
880
881    pub fn database_path(&self) -> &Path {
882        &self.paths.database
883    }
884
885    /// Cheap external-change probe for a long-running TUI.
886    pub fn revision(&self) -> Result<u64, StoreError> {
887        read_revision(&self.connection)
888    }
889
890    pub fn snapshot(&self) -> Result<StoreData, StoreError> {
891        let tx = self.connection.unchecked_transaction()?;
892        let data = load_snapshot(&tx)?;
893        tx.commit()?;
894        Ok(data)
895    }
896
897    pub fn load_settings(&self) -> Result<Settings, StoreError> {
898        Ok(self.snapshot()?.settings)
899    }
900
901    pub fn save_settings(&mut self, settings: &Settings) -> Result<(), StoreError> {
902        self.update(|data| {
903            data.replace_settings(settings.clone())?;
904            Ok(())
905        })
906    }
907
908    /// Run a read-modify-write against a fresh snapshot under
909    /// `BEGIN IMMEDIATE`. Every successful call increments `revision` once.
910    pub fn update<R>(
911        &mut self,
912        operation: impl FnOnce(&mut StoreData) -> Result<R, StoreError>,
913    ) -> Result<R, StoreError> {
914        self.update_with_snapshot(operation)
915            .map(|(result, _)| result)
916    }
917
918    /// Commit a mutation and return the exact normalized snapshot that was
919    /// persisted, including its new revision.
920    pub fn update_with_snapshot<R>(
921        &mut self,
922        operation: impl FnOnce(&mut StoreData) -> Result<R, StoreError>,
923    ) -> Result<(R, StoreData), StoreError> {
924        self.update_inner(None, operation)
925    }
926
927    /// Apply a mutation only if the caller's snapshot is still current.
928    pub fn update_if_revision<R>(
929        &mut self,
930        expected_revision: u64,
931        operation: impl FnOnce(&mut StoreData) -> Result<R, StoreError>,
932    ) -> Result<R, StoreError> {
933        self.update_if_revision_with_snapshot(expected_revision, operation)
934            .map(|(result, _)| result)
935    }
936
937    pub fn update_if_revision_with_snapshot<R>(
938        &mut self,
939        expected_revision: u64,
940        operation: impl FnOnce(&mut StoreData) -> Result<R, StoreError>,
941    ) -> Result<(R, StoreData), StoreError> {
942        self.update_inner(Some(expected_revision), operation)
943    }
944
945    fn update_inner<R>(
946        &mut self,
947        expected_revision: Option<u64>,
948        operation: impl FnOnce(&mut StoreData) -> Result<R, StoreError>,
949    ) -> Result<(R, StoreData), StoreError> {
950        let images_root = self
951            .persistent_attachments
952            .then(|| self.paths.images.clone());
953        let tx = self
954            .connection
955            .transaction_with_behavior(TransactionBehavior::Immediate)?;
956        let before = load_snapshot(&tx)?;
957        let base_revision = before.revision;
958        if let Some(expected) = expected_revision
959            && expected != base_revision
960        {
961            return Err(StoreError::Conflict {
962                expected,
963                actual: base_revision,
964            });
965        }
966        let mut data = before.clone();
967        let result = operation(&mut data)?;
968        import_task_attachments(&mut data, images_root.as_deref())?;
969        normalize_and_validate(
970            &mut data,
971            Local::now().naive_local(),
972            DueMode::NewWrite,
973            AttachmentMode::Persisted,
974        )?;
975        let next_revision = base_revision
976            .checked_add(1)
977            .ok_or_else(|| StoreError::Corrupt("revision overflow".into()))?;
978        data.revision = next_revision;
979        persist_diff(&tx, &before, &data)?;
980        tx.commit()?;
981        Ok((result, data))
982    }
983
984    fn migrate_legacy_json(&mut self) -> Result<(), StoreError> {
985        let images_root = self.paths.images.clone();
986        let tx = self
987            .connection
988            .transaction_with_behavior(TransactionBehavior::Immediate)?;
989        if migration_complete(&tx)? {
990            tx.commit()?;
991            return Ok(());
992        }
993
994        let existing = load_snapshot(&tx)?;
995        if !existing.categories.is_empty()
996            || !existing.tasks.is_empty()
997            || !existing.attachments.is_empty()
998            || existing.revision != 0
999        {
1000            return Err(StoreError::Corrupt(
1001                "database contains data but has no completed legacy migration marker".into(),
1002            ));
1003        }
1004
1005        let categories_file = read_optional_json::<CategoriesFile>(&self.paths.categories)?;
1006        let tasks_file = read_optional_json::<TasksFile>(&self.paths.tasks)?;
1007        let settings = read_optional_json::<Settings>(&self.paths.settings)?;
1008        validate_legacy_schema(
1009            &self.paths.categories,
1010            categories_file.as_ref().map(|f| f.schema),
1011        )?;
1012        validate_legacy_schema(&self.paths.tasks, tasks_file.as_ref().map(|f| f.schema))?;
1013
1014        let has_legacy = categories_file.is_some() || tasks_file.is_some() || settings.is_some();
1015        if has_legacy {
1016            let mut data = StoreData {
1017                revision: 1,
1018                categories: categories_file
1019                    .map(|file| {
1020                        file.categories
1021                            .into_iter()
1022                            .filter(|category| !category.is_all())
1023                            .collect()
1024                    })
1025                    .unwrap_or_default(),
1026                tasks: tasks_file.map(|file| file.tasks).unwrap_or_default(),
1027                settings: settings.unwrap_or_default().normalized(),
1028                attachments: Vec::new(),
1029            };
1030            import_task_attachments(&mut data, Some(&images_root))?;
1031            // Legacy relative values used the reader's current date/year. Freeze
1032            // that interpretation now so it cannot drift after migration.
1033            normalize_and_validate(
1034                &mut data,
1035                Local::now().naive_local(),
1036                DueMode::LegacyMigration,
1037                AttachmentMode::Persisted,
1038            )?;
1039            persist_diff(&tx, &existing, &data)?;
1040        }
1041        tx.execute(
1042            "INSERT INTO metadata(key, value) VALUES (?1, '1')",
1043            [LEGACY_MIGRATION_KEY],
1044        )?;
1045        tx.commit()?;
1046        Ok(())
1047    }
1048}
1049
1050pub fn resolve_data_dir(explicit: Option<PathBuf>) -> Result<PathBuf, StoreError> {
1051    resolve_data_dir_from(
1052        explicit,
1053        std::env::var_os("MACH_DIR").map(PathBuf::from),
1054        dirs::home_dir(),
1055    )
1056}
1057
1058fn resolve_data_dir_from(
1059    explicit: Option<PathBuf>,
1060    configured: Option<PathBuf>,
1061    home: Option<PathBuf>,
1062) -> Result<PathBuf, StoreError> {
1063    if let Some(dir) = explicit {
1064        return expand_user_with_home(dir, home.as_deref());
1065    }
1066    if let Some(dir) = configured {
1067        return expand_user_with_home(dir, home.as_deref());
1068    }
1069    home.map(|home| home.join(".mach")).ok_or_else(|| {
1070        StoreError::validation("could not determine the home directory; use --dir or set MACH_DIR")
1071    })
1072}
1073
1074fn expand_user(path: PathBuf) -> Result<PathBuf, StoreError> {
1075    let home = dirs::home_dir();
1076    expand_user_with_home(path, home.as_deref())
1077}
1078
1079fn expand_user_with_home(path: PathBuf, home: Option<&Path>) -> Result<PathBuf, StoreError> {
1080    if path.as_os_str().is_empty() {
1081        return Err(StoreError::validation("data directory cannot be empty"));
1082    }
1083    let text = path.to_string_lossy();
1084    if text == "~" {
1085        return home
1086            .map(Path::to_path_buf)
1087            .ok_or_else(|| StoreError::validation("cannot expand ~ without a home directory"));
1088    }
1089    if let Some(rest) = text.strip_prefix("~/") {
1090        return home
1091            .map(|home| home.join(rest))
1092            .ok_or_else(|| StoreError::validation("cannot expand ~ without a home directory"));
1093    }
1094    Ok(path)
1095}
1096
1097fn configure_connection(connection: &Connection) -> Result<(), StoreError> {
1098    connection.pragma_update(None, "foreign_keys", "ON")?;
1099    connection.pragma_update(None, "synchronous", "FULL")?;
1100    let mode: String = connection.query_row("PRAGMA journal_mode=WAL", [], |row| row.get(0))?;
1101    if !mode.eq_ignore_ascii_case("wal") {
1102        return Err(StoreError::Corrupt(format!(
1103            "SQLite refused WAL mode (using {mode})"
1104        )));
1105    }
1106    Ok(())
1107}
1108
1109fn configure_resource_limits(connection: &Connection) -> Result<(), StoreError> {
1110    connection.set_limit(Limit::SQLITE_LIMIT_LENGTH, MAX_SQLITE_VALUE_BYTES)?;
1111    Ok(())
1112}
1113
1114fn configure_in_memory_connection(connection: &Connection) -> Result<(), StoreError> {
1115    connection.pragma_update(None, "foreign_keys", "ON")?;
1116    connection.pragma_update(None, "journal_mode", "MEMORY")?;
1117    Ok(())
1118}
1119
1120fn initialize_schema(connection: &mut Connection, path: &Path) -> Result<(), StoreError> {
1121    let version: i64 = connection.query_row("PRAGMA user_version", [], |row| row.get(0))?;
1122    if version != 0 && version != DATABASE_SCHEMA_VERSION {
1123        return Err(StoreError::UnsupportedDatabaseSchema {
1124            path: path.to_path_buf(),
1125            found: version,
1126            expected: DATABASE_SCHEMA_VERSION,
1127        });
1128    }
1129
1130    let tx = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
1131    tx.execute_batch(
1132        "
1133        CREATE TABLE IF NOT EXISTS metadata (
1134            key TEXT PRIMARY KEY,
1135            value TEXT NOT NULL
1136        ) STRICT;
1137        CREATE TABLE IF NOT EXISTS app_state (
1138            id INTEGER PRIMARY KEY CHECK (id = 1),
1139            revision INTEGER NOT NULL CHECK (revision >= 0),
1140            settings_json TEXT NOT NULL
1141        ) STRICT;
1142        CREATE TABLE IF NOT EXISTS categories (
1143            id TEXT PRIMARY KEY,
1144            position INTEGER NOT NULL UNIQUE CHECK (position >= 0),
1145            name TEXT NOT NULL CHECK (length(trim(name)) > 0),
1146            name_key TEXT NOT NULL UNIQUE CHECK (length(name_key) > 0),
1147            description TEXT NOT NULL
1148        ) STRICT;
1149        CREATE TABLE IF NOT EXISTS tasks (
1150            id TEXT PRIMARY KEY,
1151            position INTEGER NOT NULL UNIQUE CHECK (position >= 0),
1152            title TEXT NOT NULL CHECK (length(trim(title)) > 0),
1153            body_json TEXT NOT NULL,
1154            due TEXT NOT NULL,
1155            created TEXT NOT NULL,
1156            done INTEGER NOT NULL CHECK (done IN (0, 1)),
1157            importance INTEGER NOT NULL CHECK (importance BETWEEN 0 AND 3),
1158            category_id TEXT REFERENCES categories(id) ON DELETE SET NULL
1159        ) STRICT;
1160        CREATE TABLE IF NOT EXISTS attachments (
1161            id TEXT PRIMARY KEY,
1162            sha256 TEXT NOT NULL UNIQUE CHECK (sha256 = id),
1163            media_type TEXT NOT NULL,
1164            byte_len INTEGER NOT NULL CHECK (byte_len > 0),
1165            storage_name TEXT NOT NULL UNIQUE
1166        ) STRICT;
1167        CREATE TABLE IF NOT EXISTS task_attachments (
1168            task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1169            block_index INTEGER NOT NULL CHECK (block_index >= 0),
1170            attachment_id TEXT NOT NULL REFERENCES attachments(id) ON DELETE RESTRICT,
1171            PRIMARY KEY (task_id, block_index)
1172        ) STRICT;
1173        CREATE INDEX IF NOT EXISTS task_attachments_by_attachment
1174            ON task_attachments(attachment_id);
1175        ",
1176    )?;
1177    let settings = serde_json::to_string(&Settings::default()).map_err(|source| {
1178        StoreError::Corrupt(format!("could not encode default settings: {source}"))
1179    })?;
1180    tx.execute(
1181        "INSERT OR IGNORE INTO app_state(id, revision, settings_json) VALUES (1, 0, ?1)",
1182        [settings],
1183    )?;
1184    if version == 0 {
1185        tx.pragma_update(None, "user_version", DATABASE_SCHEMA_VERSION)?;
1186    }
1187    tx.commit()?;
1188    Ok(())
1189}
1190
1191fn quick_check(connection: &Connection) -> Result<(), StoreError> {
1192    let result: String = connection.query_row("PRAGMA quick_check(1)", [], |row| row.get(0))?;
1193    if result != "ok" {
1194        return Err(StoreError::Corrupt(format!(
1195            "SQLite quick check failed: {result}"
1196        )));
1197    }
1198    Ok(())
1199}
1200
1201fn migration_complete(connection: &Connection) -> Result<bool, StoreError> {
1202    let value: Option<String> = connection
1203        .query_row(
1204            "SELECT value FROM metadata WHERE key = ?1",
1205            [LEGACY_MIGRATION_KEY],
1206            |row| row.get(0),
1207        )
1208        .optional()?;
1209    Ok(value.as_deref() == Some("1"))
1210}
1211
1212fn read_revision(connection: &Connection) -> Result<u64, StoreError> {
1213    let value: i64 =
1214        connection.query_row("SELECT revision FROM app_state WHERE id = 1", [], |row| {
1215            row.get(0)
1216        })?;
1217    u64::try_from(value).map_err(|_| StoreError::Corrupt(format!("negative revision {value}")))
1218}
1219
1220fn load_snapshot(connection: &Connection) -> Result<StoreData, StoreError> {
1221    let (revision, settings_json): (i64, String) = connection.query_row(
1222        "SELECT revision, settings_json FROM app_state WHERE id = 1",
1223        [],
1224        |row| Ok((row.get(0)?, row.get(1)?)),
1225    )?;
1226    let revision = u64::try_from(revision)
1227        .map_err(|_| StoreError::Corrupt(format!("negative revision {revision}")))?;
1228    let settings: Settings = serde_json::from_str(&settings_json)
1229        .map_err(|error| StoreError::Corrupt(format!("invalid settings JSON: {error}")))?;
1230
1231    let mut attachment_statement = connection.prepare(
1232        "SELECT id, sha256, media_type, byte_len, storage_name FROM attachments ORDER BY id",
1233    )?;
1234    let attachment_rows = attachment_statement.query_map([], |row| {
1235        Ok((
1236            row.get::<_, String>(0)?,
1237            row.get::<_, String>(1)?,
1238            row.get::<_, String>(2)?,
1239            row.get::<_, i64>(3)?,
1240            row.get::<_, String>(4)?,
1241        ))
1242    })?;
1243    let mut attachments = Vec::new();
1244    for row in attachment_rows {
1245        let (id, sha256, media_type, byte_len, storage_name) = row?;
1246        attachments.push(Attachment {
1247            id,
1248            sha256,
1249            media_type,
1250            byte_len: u64::try_from(byte_len).map_err(|_| {
1251                StoreError::Corrupt(format!("attachment has invalid byte length {byte_len}"))
1252            })?,
1253            storage_name,
1254        });
1255    }
1256
1257    let mut categories_statement = connection.prepare(
1258        "SELECT position, id, name, name_key, description FROM categories ORDER BY position",
1259    )?;
1260    let category_rows = categories_statement.query_map([], |row| {
1261        Ok((
1262            row.get::<_, i64>(0)?,
1263            Category {
1264                id: row.get(1)?,
1265                name: row.get(2)?,
1266                description: row.get(4)?,
1267            },
1268            row.get::<_, String>(3)?,
1269        ))
1270    })?;
1271    let mut categories = Vec::new();
1272    for (expected_position, row) in category_rows.enumerate() {
1273        if expected_position >= MAX_CATEGORY_COUNT {
1274            return Err(StoreError::Corrupt(format!(
1275                "category count exceeds {MAX_CATEGORY_COUNT}"
1276            )));
1277        }
1278        let (stored_position, category, stored_name_key) = row?;
1279        validate_stored_position(stored_position, expected_position, "category")?;
1280        let expected_name_key = category_name_key(&category.name);
1281        if stored_name_key != expected_name_key {
1282            return Err(StoreError::Corrupt(format!(
1283                "category {:?} has identity key {stored_name_key:?}, expected {expected_name_key:?}",
1284                category.id
1285            )));
1286        }
1287        categories.push(category);
1288    }
1289
1290    let mut tasks_statement = connection.prepare(
1291        "SELECT position, id, title, body_json, due, created, done, importance, category_id
1292         FROM tasks ORDER BY position",
1293    )?;
1294    let rows = tasks_statement.query_map([], |row| {
1295        Ok((
1296            row.get::<_, i64>(0)?,
1297            row.get::<_, String>(1)?,
1298            row.get::<_, String>(2)?,
1299            row.get::<_, String>(3)?,
1300            row.get::<_, String>(4)?,
1301            row.get::<_, String>(5)?,
1302            row.get::<_, i64>(6)?,
1303            row.get::<_, i64>(7)?,
1304            row.get::<_, Option<String>>(8)?,
1305        ))
1306    })?;
1307    let mut tasks = Vec::new();
1308    for (expected_position, row) in rows.enumerate() {
1309        if expected_position >= MAX_TASK_COUNT {
1310            return Err(StoreError::Corrupt(format!(
1311                "task count exceeds {MAX_TASK_COUNT}"
1312            )));
1313        }
1314        let (stored_position, id, title, body_json, due, created, done, importance, category_id) =
1315            row?;
1316        validate_stored_position(stored_position, expected_position, "task")?;
1317        let body = serde_json::from_str::<Vec<Block>>(&body_json).map_err(|error| {
1318            StoreError::Corrupt(format!("task {id:?} has invalid body JSON: {error}"))
1319        })?;
1320        let importance = u8::try_from(importance).map_err(|_| {
1321            StoreError::Corrupt(format!("task {id:?} has invalid importance {importance}"))
1322        })?;
1323        tasks.push(Task {
1324            id,
1325            title,
1326            body,
1327            due,
1328            created,
1329            done: done != 0,
1330            importance,
1331            category_id,
1332        });
1333    }
1334    validate_task_attachment_rows(connection, &tasks)?;
1335    let mut data = StoreData {
1336        revision,
1337        categories,
1338        tasks,
1339        settings,
1340        attachments,
1341    };
1342    normalize_and_validate(
1343        &mut data,
1344        Local::now().naive_local(),
1345        DueMode::Stored,
1346        AttachmentMode::Persisted,
1347    )
1348    .map_err(|error| match error {
1349        StoreError::Validation(message) => StoreError::Corrupt(message),
1350        other => other,
1351    })?;
1352    Ok(data)
1353}
1354
1355fn validate_task_attachment_rows(
1356    connection: &Connection,
1357    tasks: &[Task],
1358) -> Result<(), StoreError> {
1359    let expected: HashSet<(String, usize, String)> = tasks
1360        .iter()
1361        .flat_map(|task| {
1362            task.body
1363                .iter()
1364                .enumerate()
1365                .filter_map(|(block_index, block)| match block {
1366                    Block::Image { attachment_id } => {
1367                        Some((task.id.clone(), block_index, attachment_id.clone()))
1368                    }
1369                    _ => None,
1370                })
1371        })
1372        .collect();
1373    let mut statement = connection.prepare(
1374        "SELECT task_id, block_index, attachment_id
1375         FROM task_attachments ORDER BY task_id, block_index",
1376    )?;
1377    let rows = statement.query_map([], |row| {
1378        Ok((
1379            row.get::<_, String>(0)?,
1380            row.get::<_, i64>(1)?,
1381            row.get::<_, String>(2)?,
1382        ))
1383    })?;
1384    let mut stored = HashSet::new();
1385    for row in rows {
1386        let (task_id, block_index, attachment_id) = row?;
1387        let block_index = usize::try_from(block_index).map_err(|_| {
1388            StoreError::Corrupt(format!(
1389                "task {task_id:?} has invalid attachment reference index {block_index}"
1390            ))
1391        })?;
1392        stored.insert((task_id, block_index, attachment_id));
1393    }
1394    if stored != expected {
1395        return Err(StoreError::Corrupt(
1396            "task attachment reference rows do not match task body JSON".into(),
1397        ));
1398    }
1399    Ok(())
1400}
1401
1402fn validate_stored_position(stored: i64, expected: usize, entity: &str) -> Result<(), StoreError> {
1403    let expected = i64::try_from(expected)
1404        .map_err(|_| StoreError::Corrupt(format!("{entity} position exceeds integer range")))?;
1405    if stored != expected {
1406        return Err(StoreError::Corrupt(format!(
1407            "{entity} position {stored} is not contiguous (expected {expected})"
1408        )));
1409    }
1410    Ok(())
1411}
1412
1413/// Persist only rows whose identity, content, or position changed.
1414///
1415/// Positions and category names have UNIQUE constraints. Rows that move or
1416/// change names are first assigned transaction-private values outside the
1417/// validated application domain, which makes swaps and insertions safe without
1418/// deleting and recreating unrelated rows.
1419fn persist_diff(
1420    tx: &Transaction<'_>,
1421    before: &StoreData,
1422    after: &StoreData,
1423) -> Result<(), StoreError> {
1424    let before_categories: HashMap<&str, (usize, &Category)> = before
1425        .categories
1426        .iter()
1427        .enumerate()
1428        .map(|(position, category)| (category.id.as_str(), (position, category)))
1429        .collect();
1430    let after_categories: HashMap<&str, (usize, &Category)> = after
1431        .categories
1432        .iter()
1433        .enumerate()
1434        .map(|(position, category)| (category.id.as_str(), (position, category)))
1435        .collect();
1436    let before_tasks: HashMap<&str, (usize, &Task)> = before
1437        .tasks
1438        .iter()
1439        .enumerate()
1440        .map(|(position, task)| (task.id.as_str(), (position, task)))
1441        .collect();
1442    let after_tasks: HashMap<&str, (usize, &Task)> = after
1443        .tasks
1444        .iter()
1445        .enumerate()
1446        .map(|(position, task)| (task.id.as_str(), (position, task)))
1447        .collect();
1448    let before_attachments: HashMap<&str, &Attachment> = before
1449        .attachments
1450        .iter()
1451        .map(|attachment| (attachment.id.as_str(), attachment))
1452        .collect();
1453    let after_attachments: HashMap<&str, &Attachment> = after
1454        .attachments
1455        .iter()
1456        .map(|attachment| (attachment.id.as_str(), attachment))
1457        .collect();
1458
1459    for attachment in &before.attachments {
1460        if after_attachments.get(attachment.id.as_str()).copied() != Some(attachment) {
1461            return Err(StoreError::Validation(format!(
1462                "attachment {:?} metadata is immutable",
1463                attachment.id
1464            )));
1465        }
1466    }
1467    for attachment in &after.attachments {
1468        if !before_attachments.contains_key(attachment.id.as_str()) {
1469            tx.execute(
1470                "INSERT INTO attachments(id, sha256, media_type, byte_len, storage_name)
1471                 VALUES (?1, ?2, ?3, ?4, ?5)",
1472                params![
1473                    attachment.id,
1474                    attachment.sha256,
1475                    attachment.media_type,
1476                    sqlite_attachment_size(attachment.byte_len)?,
1477                    attachment.storage_name,
1478                ],
1479            )?;
1480        }
1481    }
1482
1483    // Remove tasks first so deleting a task and its category does not produce
1484    // an unnecessary ON DELETE SET NULL update.
1485    for task in &before.tasks {
1486        if !after_tasks.contains_key(task.id.as_str()) {
1487            execute_one(
1488                tx,
1489                "DELETE FROM tasks WHERE id = ?1",
1490                [task.id.as_str()],
1491                "task",
1492                &task.id,
1493            )?;
1494        }
1495    }
1496
1497    // Free every old identity key that may be replaced. Control characters are
1498    // rejected by validation, so these temporary values cannot collide with
1499    // application data and are never visible outside this transaction.
1500    let mut temporary_name_index = 0usize;
1501    for category in &before.categories {
1502        let name_changed_or_removed = after_categories
1503            .get(category.id.as_str())
1504            .is_none_or(|(_, current)| current.name != category.name);
1505        if name_changed_or_removed {
1506            let temporary_name = format!("\u{1f}mach-category-{temporary_name_index}");
1507            temporary_name_index += 1;
1508            execute_one(
1509                tx,
1510                "UPDATE categories SET name = ?1, name_key = ?1 WHERE id = ?2",
1511                params![temporary_name, category.id],
1512                "category",
1513                &category.id,
1514            )?;
1515        }
1516    }
1517
1518    let category_position_base = before.categories.len().max(after.categories.len());
1519    let mut category_position_offset = 0usize;
1520    for (old_position, category) in before.categories.iter().enumerate() {
1521        if let Some((new_position, _)) = after_categories.get(category.id.as_str()).copied()
1522            && new_position != old_position
1523        {
1524            let temporary = temporary_position(
1525                category_position_base,
1526                category_position_offset,
1527                "categories",
1528            )?;
1529            category_position_offset += 1;
1530            execute_one(
1531                tx,
1532                "UPDATE categories SET position = ?1 WHERE id = ?2",
1533                params![temporary, category.id],
1534                "category",
1535                &category.id,
1536            )?;
1537        }
1538    }
1539    for category in &after.categories {
1540        if !before_categories.contains_key(category.id.as_str()) {
1541            let temporary = temporary_position(
1542                category_position_base,
1543                category_position_offset,
1544                "categories",
1545            )?;
1546            category_position_offset += 1;
1547            tx.execute(
1548                "INSERT INTO categories(id, position, name, name_key, description)
1549                 VALUES (?1, ?2, ?3, ?4, ?5)",
1550                params![
1551                    category.id,
1552                    temporary,
1553                    category.name,
1554                    category_name_key(&category.name),
1555                    category.description
1556                ],
1557            )?;
1558        }
1559    }
1560
1561    let task_position_base = before.tasks.len().max(after.tasks.len());
1562    let mut task_position_offset = 0usize;
1563    for (old_position, task) in before.tasks.iter().enumerate() {
1564        if let Some((new_position, _)) = after_tasks.get(task.id.as_str()).copied()
1565            && new_position != old_position
1566        {
1567            let temporary = temporary_position(task_position_base, task_position_offset, "tasks")?;
1568            task_position_offset += 1;
1569            execute_one(
1570                tx,
1571                "UPDATE tasks SET position = ?1 WHERE id = ?2",
1572                params![temporary, task.id],
1573                "task",
1574                &task.id,
1575            )?;
1576        }
1577    }
1578
1579    // New categories now exist, so task foreign keys can safely move to them
1580    // before obsolete categories are deleted.
1581    for task in &after.tasks {
1582        if let Some((_, previous)) = before_tasks.get(task.id.as_str()).copied()
1583            && previous != task
1584        {
1585            let body_json = encode_task_body(task)?;
1586            execute_one(
1587                tx,
1588                "UPDATE tasks SET
1589                    title = ?1, body_json = ?2, due = ?3, created = ?4,
1590                    done = ?5, importance = ?6, category_id = ?7
1591                 WHERE id = ?8",
1592                params![
1593                    task.title,
1594                    body_json,
1595                    task.due,
1596                    task.created,
1597                    i64::from(task.done),
1598                    i64::from(task.importance),
1599                    task.category_id,
1600                    task.id,
1601                ],
1602                "task",
1603                &task.id,
1604            )?;
1605        }
1606    }
1607    for task in &after.tasks {
1608        if !before_tasks.contains_key(task.id.as_str()) {
1609            let temporary = temporary_position(task_position_base, task_position_offset, "tasks")?;
1610            task_position_offset += 1;
1611            let body_json = encode_task_body(task)?;
1612            tx.execute(
1613                "INSERT INTO tasks(
1614                    id, position, title, body_json, due, created, done, importance, category_id
1615                 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1616                params![
1617                    task.id,
1618                    temporary,
1619                    task.title,
1620                    body_json,
1621                    task.due,
1622                    task.created,
1623                    i64::from(task.done),
1624                    i64::from(task.importance),
1625                    task.category_id,
1626                ],
1627            )?;
1628        }
1629    }
1630
1631    for task in &after.tasks {
1632        let body_changed_or_new = before_tasks
1633            .get(task.id.as_str())
1634            .is_none_or(|(_, previous)| previous.body != task.body);
1635        if body_changed_or_new {
1636            tx.execute(
1637                "DELETE FROM task_attachments WHERE task_id = ?1",
1638                [&task.id],
1639            )?;
1640            insert_task_attachment_rows(tx, task)?;
1641        }
1642    }
1643
1644    for category in &before.categories {
1645        if !after_categories.contains_key(category.id.as_str()) {
1646            execute_one(
1647                tx,
1648                "DELETE FROM categories WHERE id = ?1",
1649                [category.id.as_str()],
1650                "category",
1651                &category.id,
1652            )?;
1653        }
1654    }
1655
1656    for category in &after.categories {
1657        if let Some((_, previous)) = before_categories.get(category.id.as_str()).copied()
1658            && previous != category
1659        {
1660            execute_one(
1661                tx,
1662                "UPDATE categories
1663                 SET name = ?1, name_key = ?2, description = ?3
1664                 WHERE id = ?4",
1665                params![
1666                    category.name,
1667                    category_name_key(&category.name),
1668                    category.description,
1669                    category.id
1670                ],
1671                "category",
1672                &category.id,
1673            )?;
1674        }
1675    }
1676
1677    // All rows whose final slots changed are currently at unique temporary
1678    // positions. Rows omitted here kept the same slot, so final assignment
1679    // cannot collide with them.
1680    for (position, category) in after.categories.iter().enumerate() {
1681        let moved_or_new = before_categories
1682            .get(category.id.as_str())
1683            .is_none_or(|(old_position, _)| *old_position != position);
1684        if moved_or_new {
1685            let position = sqlite_position(position, "categories")?;
1686            execute_one(
1687                tx,
1688                "UPDATE categories SET position = ?1 WHERE id = ?2",
1689                params![position, category.id],
1690                "category",
1691                &category.id,
1692            )?;
1693        }
1694    }
1695    for (position, task) in after.tasks.iter().enumerate() {
1696        let moved_or_new = before_tasks
1697            .get(task.id.as_str())
1698            .is_none_or(|(old_position, _)| *old_position != position);
1699        if moved_or_new {
1700            let position = sqlite_position(position, "tasks")?;
1701            execute_one(
1702                tx,
1703                "UPDATE tasks SET position = ?1 WHERE id = ?2",
1704                params![position, task.id],
1705                "task",
1706                &task.id,
1707            )?;
1708        }
1709    }
1710
1711    let settings = (before.settings != after.settings).then_some(&after.settings);
1712    persist_app_state(tx, after.revision, settings)?;
1713    Ok(())
1714}
1715
1716fn execute_one<P: rusqlite::Params>(
1717    tx: &Transaction<'_>,
1718    sql: &str,
1719    params: P,
1720    entity: &str,
1721    id: &str,
1722) -> Result<(), StoreError> {
1723    let changed = tx.execute(sql, params)?;
1724    if changed != 1 {
1725        return Err(StoreError::Corrupt(format!(
1726            "expected to change one {entity} {id:?}, changed {changed}"
1727        )));
1728    }
1729    Ok(())
1730}
1731
1732fn temporary_position(base: usize, offset: usize, entity: &str) -> Result<i64, StoreError> {
1733    let position = base
1734        .checked_add(offset)
1735        .ok_or_else(|| StoreError::Validation(format!("too many {entity}")))?;
1736    sqlite_position(position, entity)
1737}
1738
1739fn sqlite_position(position: usize, entity: &str) -> Result<i64, StoreError> {
1740    i64::try_from(position).map_err(|_| StoreError::Validation(format!("too many {entity}")))
1741}
1742
1743fn sqlite_attachment_size(byte_len: u64) -> Result<i64, StoreError> {
1744    i64::try_from(byte_len)
1745        .map_err(|_| StoreError::Validation("attachment byte length exceeds integer range".into()))
1746}
1747
1748fn insert_task_attachment_rows(tx: &Transaction<'_>, task: &Task) -> Result<(), StoreError> {
1749    let mut statement = tx.prepare(
1750        "INSERT INTO task_attachments(task_id, block_index, attachment_id)
1751         VALUES (?1, ?2, ?3)",
1752    )?;
1753    for (block_index, block) in task.body.iter().enumerate() {
1754        let Block::Image { attachment_id } = block else {
1755            continue;
1756        };
1757        statement.execute(params![
1758            task.id,
1759            sqlite_position(block_index, "task attachment blocks")?,
1760            attachment_id,
1761        ])?;
1762    }
1763    Ok(())
1764}
1765
1766fn encode_task_body(task: &Task) -> Result<String, StoreError> {
1767    serde_json::to_string(&task.body).map_err(|error| {
1768        StoreError::Corrupt(format!("could not encode task {:?}: {error}", task.id))
1769    })
1770}
1771
1772fn persist_app_state(
1773    tx: &Transaction<'_>,
1774    revision: u64,
1775    settings: Option<&Settings>,
1776) -> Result<(), StoreError> {
1777    let revision = i64::try_from(revision)
1778        .map_err(|_| StoreError::Corrupt("revision exceeds SQLite integer range".into()))?;
1779    let changed = if let Some(settings) = settings {
1780        let settings_json = serde_json::to_string(settings)
1781            .map_err(|error| StoreError::Corrupt(format!("could not encode settings: {error}")))?;
1782        tx.execute(
1783            "UPDATE app_state SET revision = ?1, settings_json = ?2 WHERE id = 1",
1784            params![revision, settings_json],
1785        )?
1786    } else {
1787        tx.execute(
1788            "UPDATE app_state SET revision = ?1 WHERE id = 1",
1789            [revision],
1790        )?
1791    };
1792    if changed != 1 {
1793        return Err(StoreError::Corrupt(format!(
1794            "expected to update app state, changed {changed} rows"
1795        )));
1796    }
1797    Ok(())
1798}
1799
1800fn import_task_attachments(
1801    data: &mut StoreData,
1802    images_root: Option<&Path>,
1803) -> Result<(), StoreError> {
1804    let mut known: HashMap<String, Attachment> = data
1805        .attachments
1806        .iter()
1807        .cloned()
1808        .map(|attachment| (attachment.id.clone(), attachment))
1809        .collect();
1810
1811    for task in &mut data.tasks {
1812        for block in &mut task.body {
1813            let Block::Image { attachment_id } = block else {
1814                continue;
1815            };
1816            if known.contains_key(attachment_id) {
1817                continue;
1818            }
1819            if is_attachment_id(attachment_id) {
1820                return Err(StoreError::Validation(format!(
1821                    "task {:?} refers to unknown attachment {attachment_id:?}",
1822                    task.id
1823                )));
1824            }
1825            let Some(images_root) = images_root else {
1826                return Err(StoreError::Validation(
1827                    "image attachments require a persistent store".into(),
1828                ));
1829            };
1830            let imported = import_attachment(attachment_id, images_root)?;
1831            if let Some(existing) = known.get(&imported.id) {
1832                if existing != &imported {
1833                    return Err(StoreError::Corrupt(format!(
1834                        "attachment {:?} metadata does not match imported content",
1835                        imported.id
1836                    )));
1837                }
1838            } else {
1839                known.insert(imported.id.clone(), imported.clone());
1840                data.attachments.push(imported.clone());
1841            }
1842            *attachment_id = imported.id;
1843        }
1844    }
1845    data.attachments
1846        .sort_by(|left, right| left.id.cmp(&right.id));
1847    Ok(())
1848}
1849
1850fn import_attachment(reference: &str, images_root: &Path) -> Result<Attachment, StoreError> {
1851    let source_path = crate::image::expand_in(reference, images_root);
1852    let mut source = fs::File::open(&source_path)
1853        .map_err(|error| StoreError::io("open image attachment", &source_path, error))?;
1854    let metadata = source
1855        .metadata()
1856        .map_err(|error| StoreError::io("inspect image attachment", &source_path, error))?;
1857    if !metadata.is_file() {
1858        return Err(StoreError::Validation(format!(
1859            "image attachment {} is not a regular file",
1860            source_path.display()
1861        )));
1862    }
1863
1864    ensure_private_directory(images_root)?;
1865    let temp_path = images_root.join(format!(".mach-attachment-{}.tmp", uuid::Uuid::new_v4()));
1866    let mut temp = open_private_attachment_temp(&temp_path)?;
1867    let result = (|| {
1868        let mut hasher = Sha256::new();
1869        let mut byte_len = 0_u64;
1870        let mut prefix = [0_u8; 32];
1871        let mut prefix_len = 0usize;
1872        let mut buffer = [0_u8; 64 * 1024];
1873        loop {
1874            let read = source
1875                .read(&mut buffer)
1876                .map_err(|error| StoreError::io("read image attachment", &source_path, error))?;
1877            if read == 0 {
1878                break;
1879            }
1880            byte_len = byte_len
1881                .checked_add(read as u64)
1882                .ok_or_else(|| StoreError::Validation("image attachment is too large".into()))?;
1883            if byte_len > MAX_ATTACHMENT_BYTES {
1884                return Err(StoreError::Validation(format!(
1885                    "image attachment {} exceeds the {} MiB safety limit",
1886                    source_path.display(),
1887                    MAX_ATTACHMENT_BYTES / 1024 / 1024
1888                )));
1889            }
1890            if prefix_len < prefix.len() {
1891                let copy = (prefix.len() - prefix_len).min(read);
1892                prefix[prefix_len..prefix_len + copy].copy_from_slice(&buffer[..copy]);
1893                prefix_len += copy;
1894            }
1895            hasher.update(&buffer[..read]);
1896            temp.write_all(&buffer[..read]).map_err(|error| {
1897                StoreError::io("write managed image attachment", &temp_path, error)
1898            })?;
1899        }
1900        if byte_len == 0 {
1901            return Err(StoreError::Validation(format!(
1902                "image attachment {} is empty",
1903                source_path.display()
1904            )));
1905        }
1906        let format = image::guess_format(&prefix[..prefix_len]).map_err(|_| {
1907            StoreError::Validation(format!(
1908                "image attachment {} is not a supported PNG, JPEG, GIF, or WebP image",
1909                source_path.display()
1910            ))
1911        })?;
1912        let (extension, media_type) = attachment_format(format).ok_or_else(|| {
1913            StoreError::Validation(format!(
1914                "image attachment {} is not a supported PNG, JPEG, GIF, or WebP image",
1915                source_path.display()
1916            ))
1917        })?;
1918        temp.sync_all()
1919            .map_err(|error| StoreError::io("sync managed image attachment", &temp_path, error))?;
1920        drop(temp);
1921        crate::image::load_dynamic(&temp_path).map_err(StoreError::Validation)?;
1922
1923        let id = format!("{:x}", hasher.finalize());
1924        let storage_name = format!("{id}.{extension}");
1925        let destination = images_root.join(&storage_name);
1926        if destination.exists() {
1927            let (stored_hash, stored_len) = hash_attachment_file(&destination)?;
1928            if stored_hash != id || stored_len != byte_len {
1929                return Err(StoreError::Corrupt(format!(
1930                    "managed attachment {} does not match its content address",
1931                    destination.display()
1932                )));
1933            }
1934            fs::remove_file(&temp_path).map_err(|error| {
1935                StoreError::io("remove duplicate image attachment", &temp_path, error)
1936            })?;
1937        } else {
1938            fs::rename(&temp_path, &destination).map_err(|error| {
1939                StoreError::io("install managed image attachment", &destination, error)
1940            })?;
1941            set_private_file(&destination)?;
1942            fs::File::open(images_root)
1943                .and_then(|directory| directory.sync_all())
1944                .map_err(|error| StoreError::io("sync image directory", images_root, error))?;
1945        }
1946        Ok(Attachment {
1947            id: id.clone(),
1948            sha256: id,
1949            media_type: media_type.into(),
1950            byte_len,
1951            storage_name,
1952        })
1953    })();
1954    if result.is_err() {
1955        let _ = fs::remove_file(&temp_path);
1956    }
1957    result
1958}
1959
1960fn open_private_attachment_temp(path: &Path) -> Result<fs::File, StoreError> {
1961    let mut options = fs::OpenOptions::new();
1962    options.write(true).create_new(true);
1963    #[cfg(unix)]
1964    {
1965        use std::os::unix::fs::OpenOptionsExt;
1966        options.mode(0o600);
1967    }
1968    options
1969        .open(path)
1970        .map_err(|error| StoreError::io("create managed image attachment", path, error))
1971}
1972
1973fn hash_attachment_file(path: &Path) -> Result<(String, u64), StoreError> {
1974    let mut file = fs::File::open(path)
1975        .map_err(|error| StoreError::io("open managed image attachment", path, error))?;
1976    let mut hasher = Sha256::new();
1977    let mut byte_len = 0_u64;
1978    let mut buffer = [0_u8; 64 * 1024];
1979    loop {
1980        let read = file
1981            .read(&mut buffer)
1982            .map_err(|error| StoreError::io("read managed image attachment", path, error))?;
1983        if read == 0 {
1984            break;
1985        }
1986        byte_len = byte_len
1987            .checked_add(read as u64)
1988            .ok_or_else(|| StoreError::Corrupt("managed attachment is too large".into()))?;
1989        if byte_len > MAX_ATTACHMENT_BYTES {
1990            return Err(StoreError::Corrupt(format!(
1991                "managed attachment {} exceeds the safety limit",
1992                path.display()
1993            )));
1994        }
1995        hasher.update(&buffer[..read]);
1996    }
1997    Ok((format!("{:x}", hasher.finalize()), byte_len))
1998}
1999
2000fn attachment_format(format: ImageFormat) -> Option<(&'static str, &'static str)> {
2001    match format {
2002        ImageFormat::Png => Some(("png", "image/png")),
2003        ImageFormat::Jpeg => Some(("jpg", "image/jpeg")),
2004        ImageFormat::Gif => Some(("gif", "image/gif")),
2005        ImageFormat::WebP => Some(("webp", "image/webp")),
2006        _ => None,
2007    }
2008}
2009
2010fn is_attachment_id(value: &str) -> bool {
2011    value.len() == ATTACHMENT_ID_LEN
2012        && value
2013            .bytes()
2014            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
2015}
2016
2017#[derive(Clone, Copy)]
2018enum DueMode {
2019    NewWrite,
2020    LegacyMigration,
2021    Stored,
2022}
2023
2024#[derive(Clone, Copy, PartialEq, Eq)]
2025enum AttachmentMode {
2026    Draft,
2027    Persisted,
2028}
2029
2030fn normalize_and_validate(
2031    data: &mut StoreData,
2032    now: NaiveDateTime,
2033    due_mode: DueMode,
2034    attachment_mode: AttachmentMode,
2035) -> Result<(), StoreError> {
2036    if data.categories.len() > MAX_CATEGORY_COUNT {
2037        return Err(StoreError::Validation(format!(
2038            "category limit is {MAX_CATEGORY_COUNT}"
2039        )));
2040    }
2041    if data.tasks.len() > MAX_TASK_COUNT {
2042        return Err(StoreError::Validation(format!(
2043            "task limit is {MAX_TASK_COUNT}"
2044        )));
2045    }
2046
2047    let attachment_ids = validate_attachments(&data.attachments)?;
2048
2049    let mut category_ids = HashSet::new();
2050    let mut category_names = HashSet::new();
2051    for category in &data.categories {
2052        validate_single_line(&category.id, "category id")?;
2053        validate_byte_limit(&category.id, ID_MAX_BYTES, "category id")?;
2054        if category.is_all() {
2055            return Err(StoreError::Validation(
2056                "real category id cannot be empty".into(),
2057            ));
2058        }
2059        if !category_ids.insert(category.id.as_str()) {
2060            return Err(StoreError::Validation(format!(
2061                "category id {:?} must be unique",
2062                category.id
2063            )));
2064        }
2065        validate_single_line(&category.name, "category name")?;
2066        validate_byte_limit(
2067            &category.name,
2068            text_byte_limit(MAX_CATEGORY_NAME_LEN),
2069            "category name",
2070        )?;
2071        let name = category.name.trim();
2072        if name.is_empty() {
2073            return Err(StoreError::Validation(
2074                "category name cannot be empty".into(),
2075            ));
2076        }
2077        if name.graphemes(true).count() > MAX_CATEGORY_NAME_LEN {
2078            return Err(StoreError::Validation(format!(
2079                "category name {:?} exceeds {MAX_CATEGORY_NAME_LEN} characters",
2080                category.name
2081            )));
2082        }
2083        if !category_names.insert(category_name_key(name)) {
2084            return Err(StoreError::Validation(format!(
2085                "category names must be unique (duplicate {:?})",
2086                category.name
2087            )));
2088        }
2089        validate_multiline(
2090            &category.description,
2091            MAX_CATEGORY_DESC_LINES,
2092            MAX_CATEGORY_DESC_LINE_LEN,
2093            "category description",
2094        )?;
2095    }
2096
2097    let mut task_ids = HashSet::new();
2098    for task in &mut data.tasks {
2099        validate_single_line(&task.id, "task id")?;
2100        validate_byte_limit(&task.id, ID_MAX_BYTES, "task id")?;
2101        if task.id.is_empty() || !task_ids.insert(task.id.as_str()) {
2102            return Err(StoreError::Validation(format!(
2103                "task id {:?} must be nonempty and unique",
2104                task.id
2105            )));
2106        }
2107        validate_single_line(&task.title, "task title")?;
2108        validate_byte_limit(&task.title, text_byte_limit(MAX_TITLE_LEN), "task title")?;
2109        if task.title.trim().is_empty() {
2110            return Err(StoreError::Validation(format!(
2111                "task {:?} title cannot be empty",
2112                task.id
2113            )));
2114        }
2115        if task.title.graphemes(true).count() > MAX_TITLE_LEN {
2116            return Err(StoreError::Validation(format!(
2117                "task {:?} title exceeds {MAX_TITLE_LEN} characters",
2118                task.id
2119            )));
2120        }
2121        if task.importance > MAX_IMPORTANCE {
2122            return Err(StoreError::Validation(format!(
2123                "task {:?} importance must be 0-{MAX_IMPORTANCE}",
2124                task.id
2125            )));
2126        }
2127        if task.body.len() > MAX_BODY_LINES {
2128            return Err(StoreError::Validation(format!(
2129                "task {:?} body exceeds {MAX_BODY_LINES} blocks",
2130                task.id
2131            )));
2132        }
2133        for block in &task.body {
2134            validate_block(block, &task.id)?;
2135            if let Block::Image { attachment_id } = block {
2136                let known = attachment_ids.contains(attachment_id.as_str());
2137                if attachment_mode == AttachmentMode::Persisted && !known {
2138                    return Err(StoreError::Validation(format!(
2139                        "task {:?} refers to unknown attachment {attachment_id:?}",
2140                        task.id
2141                    )));
2142                }
2143                if attachment_mode == AttachmentMode::Draft
2144                    && is_attachment_id(attachment_id)
2145                    && !known
2146                {
2147                    return Err(StoreError::Validation(format!(
2148                        "task {:?} refers to unknown attachment {attachment_id:?}",
2149                        task.id
2150                    )));
2151                }
2152            }
2153        }
2154        if let Some(category_id) = task.category_id.as_deref() {
2155            validate_single_line(category_id, "task category id")?;
2156            validate_byte_limit(category_id, ID_MAX_BYTES, "task category id")?;
2157            if !category_ids.contains(category_id) {
2158                return Err(StoreError::Validation(format!(
2159                    "task {:?} refers to unknown category {category_id:?}",
2160                    task.id
2161                )));
2162            }
2163        }
2164        validate_single_line(&task.due, "task due")?;
2165        validate_byte_limit(&task.due, DUE_MAX_BYTES, "task due")?;
2166        let normalized_due = match due_mode {
2167            DueMode::NewWrite => due::normalize_for_write_at(&task.due, now),
2168            DueMode::LegacyMigration => due::normalize_legacy_at(&task.due, now),
2169            DueMode::Stored => due::normalize_for_write_at(&task.due, now),
2170        }
2171        .map_err(|error| StoreError::Validation(format!("task {:?} has {error}", task.id)))?;
2172        if matches!(due_mode, DueMode::Stored) && normalized_due != task.due {
2173            return Err(StoreError::Validation(format!(
2174                "task {:?} has noncanonical due value {:?}",
2175                task.id, task.due
2176            )));
2177        }
2178        task.due = normalized_due;
2179        validate_single_line(&task.created, "task creation timestamp")?;
2180        validate_byte_limit(&task.created, CREATED_MAX_BYTES, "task creation timestamp")?;
2181        NaiveDateTime::parse_from_str(&task.created, "%Y-%m-%d %H:%M:%S").map_err(|_| {
2182            StoreError::Validation(format!(
2183                "task {:?} has invalid creation timestamp {:?}",
2184                task.id, task.created
2185            ))
2186        })?;
2187    }
2188    validate_settings(&data.settings)
2189}
2190
2191fn validate_block(block: &Block, task_id: &str) -> Result<(), StoreError> {
2192    let (kind, value) = match block {
2193        Block::Text { text } => ("text", text),
2194        Block::Todo { text, .. } => ("subtask", text),
2195        Block::Bullet { text } => ("bullet", text),
2196        Block::Number { text } => ("number", text),
2197        Block::Link { url } => ("link", url),
2198        Block::Image { attachment_id } => ("image attachment", attachment_id),
2199    };
2200    validate_single_line(value, kind)?;
2201    validate_byte_limit(value, text_byte_limit(MAX_NOTES_LINE_LEN), kind)?;
2202    if value.graphemes(true).count() > MAX_NOTES_LINE_LEN {
2203        return Err(StoreError::Validation(format!(
2204            "task {task_id:?} {kind} exceeds {MAX_NOTES_LINE_LEN} characters"
2205        )));
2206    }
2207    Ok(())
2208}
2209
2210fn validate_attachments(attachments: &[Attachment]) -> Result<HashSet<&str>, StoreError> {
2211    let mut ids = HashSet::new();
2212    let mut storage_names = HashSet::new();
2213    for attachment in attachments {
2214        if !is_attachment_id(&attachment.id) || attachment.sha256 != attachment.id {
2215            return Err(StoreError::Validation(format!(
2216                "attachment {:?} has an invalid content address",
2217                attachment.id
2218            )));
2219        }
2220        if !ids.insert(attachment.id.as_str()) {
2221            return Err(StoreError::Validation(format!(
2222                "attachment id {:?} must be unique",
2223                attachment.id
2224            )));
2225        }
2226        if attachment.byte_len == 0 || attachment.byte_len > MAX_ATTACHMENT_BYTES {
2227            return Err(StoreError::Validation(format!(
2228                "attachment {:?} has invalid byte length {}",
2229                attachment.id, attachment.byte_len
2230            )));
2231        }
2232        let extension = match attachment.media_type.as_str() {
2233            "image/png" => "png",
2234            "image/jpeg" => "jpg",
2235            "image/gif" => "gif",
2236            "image/webp" => "webp",
2237            other => {
2238                return Err(StoreError::Validation(format!(
2239                    "attachment {:?} has unsupported media type {other:?}",
2240                    attachment.id
2241                )));
2242            }
2243        };
2244        let expected_storage_name = format!("{}.{}", attachment.id, extension);
2245        if attachment.storage_name != expected_storage_name {
2246            return Err(StoreError::Validation(format!(
2247                "attachment {:?} has invalid storage name {:?}",
2248                attachment.id, attachment.storage_name
2249            )));
2250        }
2251        if !storage_names.insert(attachment.storage_name.as_str()) {
2252            return Err(StoreError::Validation(format!(
2253                "attachment storage name {:?} must be unique",
2254                attachment.storage_name
2255            )));
2256        }
2257    }
2258    Ok(ids)
2259}
2260
2261fn validate_multiline(
2262    value: &str,
2263    max_lines: usize,
2264    max_line_len: usize,
2265    label: &str,
2266) -> Result<(), StoreError> {
2267    let max_line_bytes = text_byte_limit(max_line_len);
2268    let max_total_bytes = max_lines.saturating_mul(max_line_bytes.saturating_add(1));
2269    validate_byte_limit(value, max_total_bytes, label)?;
2270    if value
2271        .chars()
2272        .any(|character| character.is_control() && character != '\n')
2273    {
2274        return Err(StoreError::Validation(format!(
2275            "{label} contains a control character"
2276        )));
2277    }
2278    for (index, line) in value.split('\n').enumerate() {
2279        if index >= max_lines {
2280            return Err(StoreError::Validation(format!(
2281                "{label} exceeds {max_lines} lines"
2282            )));
2283        }
2284        if line.len() > max_line_bytes {
2285            return Err(StoreError::Validation(format!(
2286                "{label} line exceeds {max_line_bytes} bytes"
2287            )));
2288        }
2289        if line.graphemes(true).count() > max_line_len {
2290            return Err(StoreError::Validation(format!(
2291                "{label} line exceeds {max_line_len} characters"
2292            )));
2293        }
2294    }
2295    Ok(())
2296}
2297
2298fn validate_single_line(value: &str, label: &str) -> Result<(), StoreError> {
2299    if value.chars().any(char::is_control) {
2300        return Err(StoreError::Validation(format!(
2301            "{label} contains a control character"
2302        )));
2303    }
2304    Ok(())
2305}
2306
2307fn validate_byte_limit(value: &str, max_bytes: usize, label: &str) -> Result<(), StoreError> {
2308    if value.len() > max_bytes {
2309        return Err(StoreError::Validation(format!(
2310            "{label} exceeds {max_bytes} bytes"
2311        )));
2312    }
2313    Ok(())
2314}
2315
2316/// Unicode compatibility normalization followed by full default case folding
2317/// defines category identity. A final normalization makes the key stable when
2318/// folding introduces decomposed characters.
2319fn category_name_key(value: &str) -> String {
2320    caseless_key(value.trim())
2321}
2322
2323fn category_name_has_prefix(name: &str, folded_query: &str) -> bool {
2324    let normalized: String = name.trim().nfkc().collect();
2325    normalized
2326        .char_indices()
2327        .skip(1)
2328        .map(|(index, _)| index)
2329        .chain(std::iter::once(normalized.len()))
2330        .any(|end| category_name_key(&normalized[..end]) == folded_query)
2331}
2332
2333fn validate_settings(settings: &Settings) -> Result<(), StoreError> {
2334    validate_single_line(&settings.date_format, "date format")?;
2335    validate_byte_limit(
2336        &settings.date_format,
2337        SETTINGS_VALUE_MAX_BYTES,
2338        "date format",
2339    )?;
2340    validate_single_line(&settings.selected_color, "theme")?;
2341    validate_byte_limit(&settings.selected_color, SETTINGS_VALUE_MAX_BYTES, "theme")?;
2342    validate_single_line(&settings.sort, "sort")?;
2343    validate_byte_limit(&settings.sort, SETTINGS_VALUE_MAX_BYTES, "sort")?;
2344    validate_single_line(&settings.preview_position, "preview position")?;
2345    validate_byte_limit(
2346        &settings.preview_position,
2347        SETTINGS_VALUE_MAX_BYTES,
2348        "preview position",
2349    )?;
2350    if let Some(version) = settings.last_run_version.as_deref() {
2351        validate_single_line(version, "last-run version")?;
2352        validate_byte_limit(version, SETTINGS_VALUE_MAX_BYTES, "last-run version")?;
2353    }
2354    if !DATE_FORMATS.contains(&settings.date_format.as_str()) {
2355        return Err(StoreError::Validation(format!(
2356            "unknown date format {:?}",
2357            settings.date_format
2358        )));
2359    }
2360    if !THEMES.contains(&settings.selected_color.as_str()) {
2361        return Err(StoreError::Validation(format!(
2362            "unknown theme {:?}",
2363            settings.selected_color
2364        )));
2365    }
2366    if !SORTS.contains(&settings.sort.as_str()) {
2367        return Err(StoreError::Validation(format!(
2368            "unknown sort {:?}",
2369            settings.sort
2370        )));
2371    }
2372    if !PREVIEW_POSITIONS.contains(&settings.preview_position.as_str()) {
2373        return Err(StoreError::Validation(format!(
2374            "unknown preview position {:?}",
2375            settings.preview_position
2376        )));
2377    }
2378    Ok(())
2379}
2380
2381fn read_optional_json<T: DeserializeOwned>(path: &Path) -> Result<Option<T>, StoreError> {
2382    let file = match fs::File::open(path) {
2383        Ok(file) => file,
2384        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2385        Err(error) => return Err(StoreError::io("read", path, error)),
2386    };
2387    let size = file
2388        .metadata()
2389        .map_err(|error| StoreError::io("inspect", path, error))?
2390        .len();
2391    if size > MAX_LEGACY_JSON_BYTES {
2392        return Err(StoreError::Validation(format!(
2393            "legacy file {} is larger than the {} MiB safety limit",
2394            path.display(),
2395            MAX_LEGACY_JSON_BYTES / 1024 / 1024
2396        )));
2397    }
2398    serde_json::from_reader(std::io::BufReader::new(file))
2399        .map(Some)
2400        .map_err(|source| StoreError::Json {
2401            path: path.to_path_buf(),
2402            source,
2403        })
2404}
2405
2406fn validate_legacy_schema(path: &Path, schema: Option<u32>) -> Result<(), StoreError> {
2407    if let Some(found) = schema
2408        && found != SCHEMA_VERSION
2409    {
2410        return Err(StoreError::UnsupportedLegacySchema {
2411            path: path.to_path_buf(),
2412            found,
2413            expected: SCHEMA_VERSION,
2414        });
2415    }
2416    Ok(())
2417}
2418
2419#[derive(Debug, Deserialize)]
2420struct TasksFile {
2421    schema: u32,
2422    tasks: Vec<Task>,
2423}
2424
2425#[derive(Debug, Deserialize)]
2426struct CategoriesFile {
2427    schema: u32,
2428    categories: Vec<Category>,
2429}
2430
2431fn ensure_private_directory(path: &Path) -> Result<(), StoreError> {
2432    let created = match fs::create_dir(path) {
2433        Ok(()) => true,
2434        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists && path.is_dir() => false,
2435        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2436            if let Some(parent) = path
2437                .parent()
2438                .filter(|parent| !parent.as_os_str().is_empty())
2439            {
2440                fs::create_dir_all(parent).map_err(|parent_error| {
2441                    StoreError::io("create parent directory", parent, parent_error)
2442                })?;
2443            }
2444            match fs::create_dir(path) {
2445                Ok(()) => true,
2446                Err(retry)
2447                    if retry.kind() == std::io::ErrorKind::AlreadyExists && path.is_dir() =>
2448                {
2449                    false
2450                }
2451                Err(retry) => {
2452                    return Err(StoreError::io("create directory", path, retry));
2453                }
2454            }
2455        }
2456        Err(error) => return Err(StoreError::io("create directory", path, error)),
2457    };
2458    if created {
2459        #[cfg(unix)]
2460        {
2461            use std::os::unix::fs::PermissionsExt;
2462            fs::set_permissions(path, fs::Permissions::from_mode(0o700))
2463                .map_err(|error| StoreError::io("set permissions on", path, error))?;
2464        }
2465    }
2466    Ok(())
2467}
2468
2469fn prepare_private_database_file(path: &Path) -> Result<(), StoreError> {
2470    #[cfg(unix)]
2471    {
2472        use std::os::unix::fs::OpenOptionsExt;
2473        match fs::OpenOptions::new()
2474            .write(true)
2475            .create_new(true)
2476            .mode(0o600)
2477            .open(path)
2478        {
2479            Ok(file) => drop(file),
2480            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
2481            Err(error) => return Err(StoreError::io("create database", path, error)),
2482        }
2483    }
2484    #[cfg(not(unix))]
2485    let _ = path;
2486    Ok(())
2487}
2488
2489fn set_private_file(path: &Path) -> Result<(), StoreError> {
2490    #[cfg(unix)]
2491    {
2492        use std::os::unix::fs::PermissionsExt;
2493        fs::set_permissions(path, fs::Permissions::from_mode(0o600))
2494            .map_err(|error| StoreError::io("set permissions on", path, error))?;
2495    }
2496    Ok(())
2497}
2498
2499#[cfg(test)]
2500mod tests {
2501    use super::*;
2502
2503    #[test]
2504    fn default_directory_requires_a_home_when_no_path_is_configured() {
2505        let error = resolve_data_dir_from(None, None, None)
2506            .expect_err("missing home must not silently select the working directory");
2507        assert!(matches!(error, StoreError::Validation(_)));
2508
2509        assert_eq!(
2510            resolve_data_dir_from(Some(PathBuf::from("/tmp/mach")), None, None).unwrap(),
2511            PathBuf::from("/tmp/mach")
2512        );
2513        assert_eq!(
2514            resolve_data_dir_from(None, Some(PathBuf::from("/tmp/configured")), None).unwrap(),
2515            PathBuf::from("/tmp/configured")
2516        );
2517        assert!(resolve_data_dir_from(Some(PathBuf::from("~/.mach")), None, None).is_err());
2518    }
2519}