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 rusqlite::limits::Limit;
18use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params};
19use serde::Deserialize;
20use serde::de::DeserializeOwned;
21use sha2::{Digest, Sha256};
22use unicode_normalization::UnicodeNormalization;
23use unicode_segmentation::UnicodeSegmentation;
24
25use crate::due;
26use crate::model::{
27    Block, Category, Label, LabelColor, MAX_CATEGORY_COUNT, MAX_CATEGORY_DESC_LINE_LEN,
28    MAX_CATEGORY_DESC_LINES, MAX_CATEGORY_NAME_LEN, MAX_DESCRIPTION_LINES, MAX_IMPORTANCE,
29    MAX_LABEL_COUNT, MAX_LABEL_NAME_LEN, MAX_LABELS_PER_TASK, MAX_NOTES_LINE_LEN, MAX_TASK_COUNT,
30    MAX_TITLE_LEN, SCHEMA_VERSION, Task, category_name_key, label_name_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 = 3;
36const LEGACY_MIGRATION_KEY: &str = "legacy_json_migrated";
37pub(crate) const SQLITE_BUSY_TIMEOUT: Duration = Duration::from_secs(10);
38pub(crate) const ID_MAX_BYTES: usize = 128;
39pub(crate) const DUE_MAX_BYTES: usize = 128;
40pub(crate) const 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;
44pub(crate) const MAX_ATTACHMENT_BYTES: u64 = 128 * 1024 * 1024;
45pub(crate) const 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    pub(crate) 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 labels: Vec<Label>,
181    pub tasks: Vec<Task>,
182    pub settings: Settings,
183    pub(crate) attachments: Vec<Attachment>,
184}
185
186/// Immutable metadata for one content-addressed image owned by this store.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct Attachment {
189    pub id: String,
190    pub sha256: String,
191    pub media_type: String,
192    pub byte_len: u64,
193    pub storage_name: String,
194}
195
196/// Verified attachment bytes staged by a caller for installation inside the
197/// same write transaction as their task references.
198#[derive(Debug, Clone)]
199pub(crate) struct StagedAttachment {
200    pub metadata: Attachment,
201    pub path: PathBuf,
202}
203
204#[derive(Debug, Clone, Default)]
205pub struct TaskPatch {
206    pub title: Option<String>,
207    pub description: Option<Vec<Block>>,
208    pub due: Option<String>,
209    pub done: Option<bool>,
210    pub importance: Option<u8>,
211    /// `None` leaves the category unchanged; `Some(None)` clears it.
212    pub category_id: Option<Option<String>>,
213    /// `None` leaves labels unchanged; values are normalized into store order.
214    pub label_ids: Option<Vec<String>>,
215}
216
217#[derive(Debug, Clone, Default)]
218pub struct CategoryPatch {
219    pub name: Option<String>,
220    pub description: Option<String>,
221}
222
223#[derive(Debug, Clone, Default)]
224pub struct LabelPatch {
225    pub name: Option<String>,
226    pub color: Option<LabelColor>,
227}
228
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub enum PurgeScope {
231    All,
232    Category(String),
233    Uncategorized,
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub enum RelativePosition {
238    Before,
239    After,
240}
241
242impl StoreData {
243    pub fn attachments(&self) -> &[Attachment] {
244        &self.attachments
245    }
246
247    /// Resolve a task by full id or unique id prefix.
248    pub fn resolve_task_id(&self, query: &str) -> Result<String, StoreError> {
249        let query = query.trim();
250        if query.is_empty() {
251            return Err(StoreError::validation("task id cannot be empty"));
252        }
253        validate_byte_limit(query, ID_MAX_BYTES, "task id query")?;
254        if let Some(task) = self.tasks.iter().find(|task| task.id == query) {
255            return Ok(task.id.clone());
256        }
257        let matches: Vec<_> = self
258            .tasks
259            .iter()
260            .filter(|task| task.id.starts_with(query))
261            .map(|task| task.id.clone())
262            .collect();
263        match matches.as_slice() {
264            [id] => Ok(id.clone()),
265            [] => Err(StoreError::NotFound {
266                entity: "task",
267                query: query.to_string(),
268            }),
269            _ => Err(StoreError::Ambiguous {
270                entity: "task id",
271                query: query.to_string(),
272                matches,
273            }),
274        }
275    }
276
277    /// Resolve a category by id, Unicode-caseless name, or unique name prefix.
278    pub fn resolve_category_id(&self, query: &str) -> Result<String, StoreError> {
279        let query = query.trim();
280        if query.is_empty() {
281            return Err(StoreError::validation("category name cannot be empty"));
282        }
283        if let Some(category) = self.categories.iter().find(|category| category.id == query) {
284            return Ok(category.id.clone());
285        }
286        validate_byte_limit(
287            query,
288            text_byte_limit(MAX_CATEGORY_NAME_LEN),
289            "category query",
290        )?;
291        let folded = category_name_key(query);
292        if let Some(category) = self
293            .categories
294            .iter()
295            .find(|category| category_name_key(&category.name) == folded)
296        {
297            return Ok(category.id.clone());
298        }
299        let matches: Vec<_> = self
300            .categories
301            .iter()
302            .filter(|category| category_name_has_prefix(&category.name, &folded))
303            .collect();
304        match matches.as_slice() {
305            [category] => Ok(category.id.clone()),
306            [] => Err(StoreError::NotFound {
307                entity: "category",
308                query: query.to_string(),
309            }),
310            _ => Err(StoreError::Ambiguous {
311                entity: "category",
312                query: query.to_string(),
313                matches: matches
314                    .into_iter()
315                    .map(|category| category.name.clone())
316                    .collect(),
317            }),
318        }
319    }
320
321    /// Resolve a label by id, Unicode-caseless name, or unique name prefix.
322    pub fn resolve_label_id(&self, query: &str) -> Result<String, StoreError> {
323        let query = query.trim();
324        if query.is_empty() {
325            return Err(StoreError::validation("label name cannot be empty"));
326        }
327        if let Some(label) = self.labels.iter().find(|label| label.id == query) {
328            return Ok(label.id.clone());
329        }
330        validate_byte_limit(query, text_byte_limit(MAX_LABEL_NAME_LEN), "label query")?;
331        let folded = label_name_key(query);
332        if let Some(label) = self
333            .labels
334            .iter()
335            .find(|label| label_name_key(&label.name) == folded)
336        {
337            return Ok(label.id.clone());
338        }
339        let matches: Vec<_> = self
340            .labels
341            .iter()
342            .filter(|label| label_name_has_prefix(&label.name, &folded))
343            .collect();
344        match matches.as_slice() {
345            [label] => Ok(label.id.clone()),
346            [] => Err(StoreError::NotFound {
347                entity: "label",
348                query: query.to_string(),
349            }),
350            _ => Err(StoreError::Ambiguous {
351                entity: "label",
352                query: query.to_string(),
353                matches: matches
354                    .into_iter()
355                    .map(|label| label.name.clone())
356                    .collect(),
357            }),
358        }
359    }
360
361    pub fn task(&self, id: &str) -> Result<&Task, StoreError> {
362        self.task_index(id).map(|index| &self.tasks[index])
363    }
364
365    fn task_index(&self, id: &str) -> Result<usize, StoreError> {
366        self.tasks
367            .iter()
368            .position(|task| task.id == id)
369            .ok_or_else(|| StoreError::NotFound {
370                entity: "task",
371                query: id.to_string(),
372            })
373    }
374
375    pub fn category(&self, id: &str) -> Result<&Category, StoreError> {
376        self.category_index(id).map(|index| &self.categories[index])
377    }
378
379    pub fn label(&self, id: &str) -> Result<&Label, StoreError> {
380        self.label_index(id).map(|index| &self.labels[index])
381    }
382
383    fn label_index(&self, id: &str) -> Result<usize, StoreError> {
384        self.labels
385            .iter()
386            .position(|label| label.id == id)
387            .ok_or_else(|| StoreError::NotFound {
388                entity: "label",
389                query: id.to_string(),
390            })
391    }
392
393    fn category_index(&self, id: &str) -> Result<usize, StoreError> {
394        self.categories
395            .iter()
396            .position(|category| category.id == id)
397            .ok_or_else(|| StoreError::NotFound {
398                entity: "category",
399                query: id.to_string(),
400            })
401    }
402
403    pub fn create_task(
404        &mut self,
405        title: impl Into<String>,
406        description: Vec<Block>,
407        due: impl Into<String>,
408        importance: u8,
409        category_id: Option<String>,
410    ) -> Result<Task, StoreError> {
411        if importance > MAX_IMPORTANCE {
412            return Err(StoreError::validation(format!(
413                "importance must be 0-{MAX_IMPORTANCE}"
414            )));
415        }
416        let title = title.into();
417        let due = due.into();
418        let mut task = Task::new(&title, importance, category_id, &due);
419        task.description = description;
420        self.insert_task(task)
421    }
422
423    pub fn insert_task(&mut self, task: Task) -> Result<Task, StoreError> {
424        let index = self.tasks.len();
425        self.tasks.push(task);
426        if let Err(error) = self.normalize_and_validate_new_write() {
427            self.tasks.truncate(index);
428            return Err(error);
429        }
430        Ok(self.tasks[index].clone())
431    }
432
433    pub fn edit_task(&mut self, id: &str, patch: TaskPatch) -> Result<Task, StoreError> {
434        let index = self.task_index(id)?;
435        let before = self.tasks[index].clone();
436        {
437            let task = &mut self.tasks[index];
438            if let Some(title) = patch.title {
439                task.title = title;
440            }
441            if let Some(description) = patch.description {
442                task.description = description;
443            }
444            if let Some(due) = patch.due {
445                task.due = due;
446            }
447            if let Some(done) = patch.done {
448                task.done = done;
449            }
450            if let Some(importance) = patch.importance {
451                task.importance = importance;
452            }
453            if let Some(category_id) = patch.category_id {
454                task.category_id = category_id;
455            }
456            if let Some(label_ids) = patch.label_ids {
457                task.label_ids = label_ids;
458            }
459        }
460        if let Err(error) = self.normalize_and_validate_new_write() {
461            self.tasks[index] = before;
462            return Err(error);
463        }
464        Ok(self.tasks[index].clone())
465    }
466
467    /// Apply only the fields represented by `patch`, but fail if one of those
468    /// fields changed since `expected` was loaded. Unrelated concurrent edits
469    /// (for example toggling `done` while a title form is open) are preserved.
470    pub fn edit_task_if_unchanged(
471        &mut self,
472        expected: &Task,
473        patch: TaskPatch,
474    ) -> Result<Task, StoreError> {
475        let current = self
476            .tasks
477            .iter()
478            .find(|task| task.id == expected.id)
479            .ok_or_else(|| StoreError::StaleEntity {
480                entity: "task",
481                id: expected.id.clone(),
482            })?;
483        let stale = field_conflicts(patch.title.as_ref(), &current.title, &expected.title)
484            || field_conflicts(
485                patch.description.as_ref(),
486                &current.description,
487                &expected.description,
488            )
489            || field_conflicts(patch.due.as_ref(), &current.due, &expected.due)
490            || field_conflicts(patch.done.as_ref(), &current.done, &expected.done)
491            || field_conflicts(
492                patch.importance.as_ref(),
493                &current.importance,
494                &expected.importance,
495            )
496            || field_conflicts(
497                patch.category_id.as_ref(),
498                &current.category_id,
499                &expected.category_id,
500            )
501            || field_conflicts(
502                patch.label_ids.as_ref(),
503                &current.label_ids,
504                &expected.label_ids,
505            );
506        if stale {
507            return Err(StoreError::StaleEntity {
508                entity: "task",
509                id: expected.id.clone(),
510            });
511        }
512        self.edit_task(&expected.id, patch)
513    }
514
515    pub fn delete_task(&mut self, id: &str) -> Result<Task, StoreError> {
516        let index = self.task_index(id)?;
517        Ok(self.tasks.remove(index))
518    }
519
520    pub fn set_task_done(&mut self, id: &str, done: bool) -> Result<Task, StoreError> {
521        self.edit_task(
522            id,
523            TaskPatch {
524                done: Some(done),
525                ..TaskPatch::default()
526            },
527        )
528    }
529
530    pub fn toggle_task_done(&mut self, id: &str) -> Result<Task, StoreError> {
531        let done = !self.task(id)?.done;
532        self.set_task_done(id, done)
533    }
534
535    pub fn set_task_importance(&mut self, id: &str, importance: u8) -> Result<Task, StoreError> {
536        self.edit_task(
537            id,
538            TaskPatch {
539                importance: Some(importance),
540                ..TaskPatch::default()
541            },
542        )
543    }
544
545    pub fn set_task_category(
546        &mut self,
547        id: &str,
548        category_id: Option<String>,
549    ) -> Result<Task, StoreError> {
550        self.edit_task(
551            id,
552            TaskPatch {
553                category_id: Some(category_id),
554                ..TaskPatch::default()
555            },
556        )
557    }
558
559    pub fn set_task_labels(
560        &mut self,
561        id: &str,
562        label_ids: Vec<String>,
563    ) -> Result<Task, StoreError> {
564        self.edit_task(
565            id,
566            TaskPatch {
567                label_ids: Some(label_ids),
568                ..TaskPatch::default()
569            },
570        )
571    }
572
573    pub fn move_task(&mut self, id: &str, target: usize) -> Result<(), StoreError> {
574        let index = self.task_index(id)?;
575        if target >= self.tasks.len() {
576            return Err(StoreError::validation(format!(
577                "task target index {target} is out of range"
578            )));
579        }
580        if self.tasks[index].category_id != self.tasks[target].category_id {
581            return Err(StoreError::validation(
582                "tasks can only be reordered within the same category",
583            ));
584        }
585        let task = self.tasks.remove(index);
586        self.tasks.insert(target, task);
587        Ok(())
588    }
589
590    pub fn move_task_relative(
591        &mut self,
592        id: &str,
593        target_id: &str,
594        position: RelativePosition,
595    ) -> Result<Task, StoreError> {
596        if id == target_id {
597            return Err(StoreError::validation(
598                "cannot move a task relative to itself",
599            ));
600        }
601        let index = self.task_index(id)?;
602        let target_index = self.task_index(target_id)?;
603        if self.tasks[index].category_id != self.tasks[target_index].category_id {
604            return Err(StoreError::validation(
605                "tasks can only be reordered within the same category",
606            ));
607        }
608        let task = self.tasks.remove(index);
609        let target_after_removal = target_index - usize::from(index < target_index);
610        let insertion = match position {
611            RelativePosition::Before => target_after_removal,
612            RelativePosition::After => target_after_removal + 1,
613        };
614        self.tasks.insert(insertion, task.clone());
615        Ok(task)
616    }
617
618    pub fn purge_completed(&mut self, scope: &PurgeScope) -> Result<Vec<Task>, StoreError> {
619        if let PurgeScope::Category(id) = scope {
620            self.category(id)?;
621        }
622        Ok(self.remove_tasks(|task| {
623            let in_scope = match scope {
624                PurgeScope::All => true,
625                PurgeScope::Category(id) => task.category_id.as_deref() == Some(id),
626                PurgeScope::Uncategorized => task.category_id.is_none(),
627            };
628            task.done && in_scope
629        }))
630    }
631
632    /// Purge only the completed tasks captured by a confirmation prompt.
633    pub fn purge_completed_ids(&mut self, ids: &[String]) -> Result<Vec<Task>, StoreError> {
634        let ids: HashSet<_> = ids.iter().map(String::as_str).collect();
635        Ok(self.remove_tasks(|task| task.done && ids.contains(task.id.as_str())))
636    }
637
638    fn remove_tasks(&mut self, mut should_remove: impl FnMut(&Task) -> bool) -> Vec<Task> {
639        let mut removed = Vec::new();
640        self.tasks.retain(|task| {
641            let remove = should_remove(task);
642            if remove {
643                removed.push(task.clone());
644            }
645            !remove
646        });
647        removed
648    }
649
650    pub fn create_category(
651        &mut self,
652        name: impl Into<String>,
653        description: impl Into<String>,
654    ) -> Result<Category, StoreError> {
655        let name = name.into();
656        let mut category = Category::new(&name);
657        category.description = description.into();
658        self.insert_category(category)
659    }
660
661    pub fn insert_category(&mut self, category: Category) -> Result<Category, StoreError> {
662        let index = self.categories.len();
663        self.categories.push(category);
664        if let Err(error) = self.normalize_and_validate_new_write() {
665            self.categories.truncate(index);
666            return Err(error);
667        }
668        Ok(self.categories[index].clone())
669    }
670
671    pub fn edit_category(
672        &mut self,
673        id: &str,
674        patch: CategoryPatch,
675    ) -> Result<Category, StoreError> {
676        let index = self.category_index(id)?;
677        let before = self.categories[index].clone();
678        {
679            let category = &mut self.categories[index];
680            if let Some(name) = patch.name {
681                category.name = name;
682            }
683            if let Some(description) = patch.description {
684                category.description = description;
685            }
686        }
687        if let Err(error) = self.normalize_and_validate_new_write() {
688            self.categories[index] = before;
689            return Err(error);
690        }
691        Ok(self.categories[index].clone())
692    }
693
694    pub fn edit_category_if_unchanged(
695        &mut self,
696        expected: &Category,
697        patch: CategoryPatch,
698    ) -> Result<Category, StoreError> {
699        let current = self
700            .categories
701            .iter()
702            .find(|category| category.id == expected.id)
703            .ok_or_else(|| StoreError::StaleEntity {
704                entity: "category",
705                id: expected.id.clone(),
706            })?;
707        let stale = field_conflicts(patch.name.as_ref(), &current.name, &expected.name)
708            || field_conflicts(
709                patch.description.as_ref(),
710                &current.description,
711                &expected.description,
712            );
713        if stale {
714            return Err(StoreError::StaleEntity {
715                entity: "category",
716                id: expected.id.clone(),
717            });
718        }
719        self.edit_category(&expected.id, patch)
720    }
721
722    /// Delete a category while preserving its tasks as uncategorized.
723    pub fn delete_category(&mut self, id: &str) -> Result<Category, StoreError> {
724        let index = self.category_index(id)?;
725        let category = self.categories.remove(index);
726        for task in &mut self.tasks {
727            if task.category_id.as_deref() == Some(id) {
728                task.category_id = None;
729            }
730        }
731        Ok(category)
732    }
733
734    pub fn create_label(&mut self, name: impl Into<String>) -> Result<Label, StoreError> {
735        let color = LabelColor::least_used(&self.labels);
736        self.create_label_with_color(name, color)
737    }
738
739    pub fn create_label_with_color(
740        &mut self,
741        name: impl Into<String>,
742        color: LabelColor,
743    ) -> Result<Label, StoreError> {
744        let name = name.into();
745        self.insert_label(Label::new(&name, color))
746    }
747
748    pub fn insert_label(&mut self, label: Label) -> Result<Label, StoreError> {
749        let index = self.labels.len();
750        self.labels.push(label);
751        if let Err(error) = self.normalize_and_validate_new_write() {
752            self.labels.truncate(index);
753            return Err(error);
754        }
755        Ok(self.labels[index].clone())
756    }
757
758    pub fn edit_label(&mut self, id: &str, patch: LabelPatch) -> Result<Label, StoreError> {
759        let index = self.label_index(id)?;
760        let before = self.labels[index].clone();
761        if let Some(name) = patch.name {
762            self.labels[index].name = name;
763        }
764        if let Some(color) = patch.color {
765            self.labels[index].color = color;
766        }
767        if let Err(error) = self.normalize_and_validate_new_write() {
768            self.labels[index] = before;
769            return Err(error);
770        }
771        Ok(self.labels[index].clone())
772    }
773
774    /// Delete a global label while preserving every task that used it.
775    pub fn delete_label(&mut self, id: &str) -> Result<Label, StoreError> {
776        let index = self.label_index(id)?;
777        let label = self.labels.remove(index);
778        for task in &mut self.tasks {
779            task.label_ids.retain(|label_id| label_id != id);
780        }
781        Ok(label)
782    }
783
784    pub fn move_category(&mut self, id: &str, target: usize) -> Result<(), StoreError> {
785        let index = self.category_index(id)?;
786        if target >= self.categories.len() {
787            return Err(StoreError::validation(format!(
788                "category target index {target} is out of range"
789            )));
790        }
791        let category = self.categories.remove(index);
792        self.categories.insert(target, category);
793        Ok(())
794    }
795
796    pub fn move_category_relative(
797        &mut self,
798        id: &str,
799        target_id: &str,
800        position: RelativePosition,
801    ) -> Result<Category, StoreError> {
802        if id == target_id {
803            return Err(StoreError::validation(
804                "cannot move a category relative to itself",
805            ));
806        }
807        let index = self.category_index(id)?;
808        let target_index = self.category_index(target_id)?;
809        let category = self.categories.remove(index);
810        let target_after_removal = target_index - usize::from(index < target_index);
811        let insertion = match position {
812            RelativePosition::Before => target_after_removal,
813            RelativePosition::After => target_after_removal + 1,
814        };
815        self.categories.insert(insertion, category.clone());
816        Ok(category)
817    }
818
819    pub fn replace_settings(&mut self, settings: Settings) -> Result<Settings, StoreError> {
820        self.update_settings(move |current| *current = settings)
821    }
822
823    pub fn update_settings(
824        &mut self,
825        operation: impl FnOnce(&mut Settings),
826    ) -> Result<Settings, StoreError> {
827        let before = self.settings.clone();
828        operation(&mut self.settings);
829        if let Err(error) = validate_settings(&self.settings) {
830            self.settings = before;
831            return Err(error);
832        }
833        Ok(self.settings.clone())
834    }
835
836    pub(crate) fn validate_as_stored(&mut self) -> Result<(), StoreError> {
837        normalize_and_validate(
838            self,
839            Local::now().naive_local(),
840            DueMode::Stored,
841            AttachmentMode::Persisted,
842        )
843    }
844
845    fn normalize_and_validate_new_write(&mut self) -> Result<(), StoreError> {
846        normalize_and_validate(
847            self,
848            Local::now().naive_local(),
849            DueMode::NewWrite,
850            AttachmentMode::Draft,
851        )
852    }
853}
854
855/// A three-way field merge conflicts only when the remote and desired values
856/// both diverged from the captured base in different directions.
857fn field_conflicts<T: PartialEq>(desired: Option<&T>, current: &T, expected: &T) -> bool {
858    desired.is_some_and(|desired| current != expected && current != desired)
859}
860
861#[derive(Debug, Clone)]
862pub struct Paths {
863    pub dir: PathBuf,
864    pub database: PathBuf,
865    pub tasks: PathBuf,
866    pub categories: PathBuf,
867    pub settings: PathBuf,
868    pub images: PathBuf,
869}
870
871impl Paths {
872    fn new(dir: PathBuf) -> Self {
873        Self {
874            database: dir.join(DATABASE_FILE),
875            tasks: dir.join("tasks.json"),
876            categories: dir.join("categories.json"),
877            settings: dir.join("settings.json"),
878            images: dir.join("images"),
879            dir,
880        }
881    }
882}
883
884pub struct Store {
885    connection: Connection,
886    paths: Paths,
887    persistent_attachments: bool,
888}
889
890impl Store {
891    pub fn open(dir: impl AsRef<Path>) -> Result<Self, StoreError> {
892        let paths = Paths::new(expand_user(dir.as_ref().to_path_buf())?);
893        ensure_private_directory(&paths.dir)?;
894        prepare_private_database_file(&paths.database)?;
895        let mut connection = Connection::open(&paths.database)?;
896        set_private_file(&paths.database)?;
897        connection.busy_timeout(SQLITE_BUSY_TIMEOUT)?;
898        configure_resource_limits(&connection)?;
899        initialize_schema(&mut connection, &paths.database)?;
900        configure_connection(&connection)?;
901        sqlite_quick_check(&connection, "")?;
902        let mut store = Self {
903            connection,
904            paths,
905            persistent_attachments: true,
906        };
907        store.migrate_legacy_json()?;
908        store.reconcile_attachment_storage()?;
909        Ok(store)
910    }
911
912    /// Open an ephemeral store with no filesystem persistence.
913    ///
914    /// `data_dir` is only the logical base for relative image references. The
915    /// directory is not created or modified.
916    pub fn open_in_memory_with_paths(data_dir: impl AsRef<Path>) -> Result<Self, StoreError> {
917        let paths = Paths::new(expand_user(data_dir.as_ref().to_path_buf())?);
918        let mut connection = Connection::open_in_memory()?;
919        connection.busy_timeout(SQLITE_BUSY_TIMEOUT)?;
920        configure_resource_limits(&connection)?;
921        initialize_schema(&mut connection, Path::new(":memory:"))?;
922        configure_in_memory_connection(&connection)?;
923        sqlite_quick_check(&connection, "")?;
924        connection.execute(
925            "INSERT INTO metadata(key, value) VALUES (?1, '1')",
926            [LEGACY_MIGRATION_KEY],
927        )?;
928        Ok(Self {
929            connection,
930            paths,
931            persistent_attachments: false,
932        })
933    }
934
935    pub fn open_default(explicit: Option<PathBuf>) -> Result<Self, StoreError> {
936        Self::open(resolve_data_dir(explicit)?)
937    }
938
939    pub fn paths(&self) -> &Paths {
940        &self.paths
941    }
942
943    pub fn data_dir(&self) -> &Path {
944        &self.paths.dir
945    }
946
947    pub fn images_dir(&self) -> &Path {
948        &self.paths.images
949    }
950
951    pub fn database_path(&self) -> &Path {
952        &self.paths.database
953    }
954
955    /// Cheap external-change probe for a long-running TUI.
956    pub fn revision(&self) -> Result<u64, StoreError> {
957        read_revision(&self.connection)
958    }
959
960    pub fn snapshot(&self) -> Result<StoreData, StoreError> {
961        let tx = self.connection.unchecked_transaction()?;
962        let data = load_snapshot(&tx)?;
963        tx.commit()?;
964        Ok(data)
965    }
966
967    pub fn load_settings(&self) -> Result<Settings, StoreError> {
968        Ok(self.snapshot()?.settings)
969    }
970
971    pub fn save_settings(&mut self, settings: &Settings) -> Result<(), StoreError> {
972        self.update(|data| {
973            data.replace_settings(settings.clone())?;
974            Ok(())
975        })
976    }
977
978    /// Run a read-modify-write against a fresh snapshot under
979    /// `BEGIN IMMEDIATE`. Every successful call increments `revision` once.
980    pub fn update<R>(
981        &mut self,
982        operation: impl FnOnce(&mut StoreData) -> Result<R, StoreError>,
983    ) -> Result<R, StoreError> {
984        self.update_with_snapshot(operation)
985            .map(|(result, _)| result)
986    }
987
988    /// Commit a mutation and return the exact normalized snapshot that was
989    /// persisted, including its new revision.
990    pub fn update_with_snapshot<R>(
991        &mut self,
992        operation: impl FnOnce(&mut StoreData) -> Result<R, StoreError>,
993    ) -> Result<(R, StoreData), StoreError> {
994        self.update_inner(None, &[], operation)
995    }
996
997    /// Apply a mutation only if the caller's snapshot is still current.
998    pub fn update_if_revision<R>(
999        &mut self,
1000        expected_revision: u64,
1001        operation: impl FnOnce(&mut StoreData) -> Result<R, StoreError>,
1002    ) -> Result<R, StoreError> {
1003        self.update_if_revision_with_snapshot(expected_revision, operation)
1004            .map(|(result, _)| result)
1005    }
1006
1007    pub fn update_if_revision_with_snapshot<R>(
1008        &mut self,
1009        expected_revision: u64,
1010        operation: impl FnOnce(&mut StoreData) -> Result<R, StoreError>,
1011    ) -> Result<(R, StoreData), StoreError> {
1012        self.update_inner(Some(expected_revision), &[], operation)
1013    }
1014
1015    pub(crate) fn update_if_revision_with_staged_attachments<R>(
1016        &mut self,
1017        expected_revision: u64,
1018        staged_attachments: &[StagedAttachment],
1019        operation: impl FnOnce(&mut StoreData) -> Result<R, StoreError>,
1020    ) -> Result<(R, StoreData), StoreError> {
1021        self.update_inner(Some(expected_revision), staged_attachments, operation)
1022    }
1023
1024    fn update_inner<R>(
1025        &mut self,
1026        expected_revision: Option<u64>,
1027        staged_attachments: &[StagedAttachment],
1028        operation: impl FnOnce(&mut StoreData) -> Result<R, StoreError>,
1029    ) -> Result<(R, StoreData), StoreError> {
1030        let images_root = self
1031            .persistent_attachments
1032            .then(|| self.paths.images.clone());
1033        let tx = self
1034            .connection
1035            .transaction_with_behavior(TransactionBehavior::Immediate)?;
1036        let mut installed_attachments = Vec::new();
1037        let prepared = (|| {
1038            let before = load_snapshot(&tx)?;
1039            let base_revision = before.revision;
1040            if let Some(expected) = expected_revision
1041                && expected != base_revision
1042            {
1043                return Err(StoreError::Conflict {
1044                    expected,
1045                    actual: base_revision,
1046                });
1047            }
1048            let mut data = before.clone();
1049            let result = operation(&mut data)?;
1050            import_task_description_attachments(
1051                &mut data,
1052                images_root.as_deref(),
1053                staged_attachments,
1054                &mut installed_attachments,
1055            )?;
1056            prune_unreferenced_attachments(&mut data);
1057            normalize_and_validate(
1058                &mut data,
1059                Local::now().naive_local(),
1060                DueMode::NewWrite,
1061                AttachmentMode::Persisted,
1062            )?;
1063            let next_revision = base_revision
1064                .checked_add(1)
1065                .ok_or_else(|| StoreError::Corrupt("revision overflow".into()))?;
1066            data.revision = next_revision;
1067            persist_diff(&tx, &before, &data)?;
1068            Ok((result, data))
1069        })();
1070
1071        let prepared = match prepared {
1072            Ok(prepared) => prepared,
1073            Err(error) => {
1074                let cleanup = remove_installed_attachment_files(&installed_attachments);
1075                let rollback = tx.rollback();
1076                cleanup?;
1077                rollback?;
1078                return Err(error);
1079            }
1080        };
1081
1082        if let Err(error) = tx.commit() {
1083            if let Some(images_root) = images_root.as_deref() {
1084                cleanup_attachments_after_failed_commit(
1085                    &mut self.connection,
1086                    images_root,
1087                    &installed_attachments,
1088                )?;
1089            }
1090            return Err(error.into());
1091        }
1092        let _ = self.cleanup_pending_attachments();
1093        Ok(prepared)
1094    }
1095
1096    fn migrate_legacy_json(&mut self) -> Result<(), StoreError> {
1097        let images_root = self.paths.images.clone();
1098        let tx = self
1099            .connection
1100            .transaction_with_behavior(TransactionBehavior::Immediate)?;
1101        if migration_complete(&tx)? {
1102            tx.commit()?;
1103            return Ok(());
1104        }
1105        let mut installed_attachments = Vec::new();
1106        let prepared = (|| {
1107            let existing = load_snapshot(&tx)?;
1108            if !existing.categories.is_empty()
1109                || !existing.tasks.is_empty()
1110                || !existing.attachments.is_empty()
1111                || existing.revision != 0
1112            {
1113                return Err(StoreError::Corrupt(
1114                    "database contains data but has no completed legacy migration marker".into(),
1115                ));
1116            }
1117
1118            let categories_file = read_optional_json::<CategoriesFile>(&self.paths.categories)?;
1119            let tasks_file = read_optional_json::<TasksFile>(&self.paths.tasks)?;
1120            let settings = read_optional_json::<Settings>(&self.paths.settings)?;
1121            validate_legacy_schema(
1122                &self.paths.categories,
1123                categories_file.as_ref().map(|file| file.schema),
1124            )?;
1125            validate_legacy_schema(
1126                &self.paths.tasks,
1127                tasks_file.as_ref().map(|file| file.schema),
1128            )?;
1129
1130            let has_legacy =
1131                categories_file.is_some() || tasks_file.is_some() || settings.is_some();
1132            if has_legacy {
1133                let mut data = StoreData {
1134                    revision: 1,
1135                    categories: categories_file
1136                        .map(|file| {
1137                            file.categories
1138                                .into_iter()
1139                                .filter(|category| !category.is_all())
1140                                .collect()
1141                        })
1142                        .unwrap_or_default(),
1143                    labels: Vec::new(),
1144                    tasks: tasks_file.map(|file| file.tasks).unwrap_or_default(),
1145                    settings: settings.unwrap_or_default().normalized(),
1146                    attachments: Vec::new(),
1147                };
1148                import_task_description_attachments(
1149                    &mut data,
1150                    Some(&images_root),
1151                    &[],
1152                    &mut installed_attachments,
1153                )?;
1154                prune_unreferenced_attachments(&mut data);
1155                // Legacy relative values used the reader's current date/year. Freeze
1156                // that interpretation now so it cannot drift after migration.
1157                normalize_and_validate(
1158                    &mut data,
1159                    Local::now().naive_local(),
1160                    DueMode::LegacyMigration,
1161                    AttachmentMode::Persisted,
1162                )?;
1163                persist_diff(&tx, &existing, &data)?;
1164            }
1165            tx.execute(
1166                "INSERT INTO metadata(key, value) VALUES (?1, '1')",
1167                [LEGACY_MIGRATION_KEY],
1168            )?;
1169            Ok(())
1170        })();
1171
1172        if let Err(error) = prepared {
1173            let cleanup = remove_installed_attachment_files(&installed_attachments);
1174            let rollback = tx.rollback();
1175            cleanup?;
1176            rollback?;
1177            return Err(error);
1178        }
1179        if let Err(error) = tx.commit() {
1180            cleanup_attachments_after_failed_commit(
1181                &mut self.connection,
1182                &images_root,
1183                &installed_attachments,
1184            )?;
1185            return Err(error.into());
1186        }
1187        Ok(())
1188    }
1189
1190    fn cleanup_pending_attachments(&mut self) -> Result<(), StoreError> {
1191        if self.persistent_attachments {
1192            cleanup_pending_attachment_files(&mut self.connection, &self.paths.images)?;
1193        }
1194        Ok(())
1195    }
1196
1197    fn reconcile_attachment_storage(&mut self) -> Result<(), StoreError> {
1198        if self.persistent_attachments {
1199            reconcile_attachment_files(&mut self.connection, &self.paths.images)?;
1200        }
1201        Ok(())
1202    }
1203}
1204
1205pub fn resolve_data_dir(explicit: Option<PathBuf>) -> Result<PathBuf, StoreError> {
1206    resolve_data_dir_from(
1207        explicit,
1208        std::env::var_os("MACH_DIR").map(PathBuf::from),
1209        dirs::home_dir(),
1210    )
1211}
1212
1213fn resolve_data_dir_from(
1214    explicit: Option<PathBuf>,
1215    configured: Option<PathBuf>,
1216    home: Option<PathBuf>,
1217) -> Result<PathBuf, StoreError> {
1218    if let Some(dir) = explicit.or(configured) {
1219        return expand_user_with_home(dir, home.as_deref());
1220    }
1221    home.map(|home| home.join(".mach")).ok_or_else(|| {
1222        StoreError::validation("could not determine the home directory; use --dir or set MACH_DIR")
1223    })
1224}
1225
1226fn expand_user(path: PathBuf) -> Result<PathBuf, StoreError> {
1227    let home = dirs::home_dir();
1228    expand_user_with_home(path, home.as_deref())
1229}
1230
1231fn expand_user_with_home(path: PathBuf, home: Option<&Path>) -> Result<PathBuf, StoreError> {
1232    if path.as_os_str().is_empty() {
1233        return Err(StoreError::validation("data directory cannot be empty"));
1234    }
1235    let text = path.to_string_lossy();
1236    if text == "~" {
1237        return home
1238            .map(Path::to_path_buf)
1239            .ok_or_else(|| StoreError::validation("cannot expand ~ without a home directory"));
1240    }
1241    if let Some(rest) = text.strip_prefix("~/") {
1242        return home
1243            .map(|home| home.join(rest))
1244            .ok_or_else(|| StoreError::validation("cannot expand ~ without a home directory"));
1245    }
1246    Ok(path)
1247}
1248
1249pub(crate) fn configure_connection(connection: &Connection) -> Result<(), StoreError> {
1250    connection.pragma_update(None, "foreign_keys", "ON")?;
1251    connection.pragma_update(None, "synchronous", "FULL")?;
1252    let mode: String = connection.query_row("PRAGMA journal_mode=WAL", [], |row| row.get(0))?;
1253    if !mode.eq_ignore_ascii_case("wal") {
1254        return Err(StoreError::Corrupt(format!(
1255            "SQLite refused WAL mode (using {mode})"
1256        )));
1257    }
1258    Ok(())
1259}
1260
1261pub(crate) fn configure_resource_limits(connection: &Connection) -> Result<(), StoreError> {
1262    connection.set_limit(Limit::SQLITE_LIMIT_LENGTH, MAX_SQLITE_VALUE_BYTES)?;
1263    Ok(())
1264}
1265
1266fn configure_in_memory_connection(connection: &Connection) -> Result<(), StoreError> {
1267    connection.pragma_update(None, "foreign_keys", "ON")?;
1268    connection.pragma_update(None, "journal_mode", "MEMORY")?;
1269    Ok(())
1270}
1271
1272fn initialize_schema(connection: &mut Connection, path: &Path) -> Result<(), StoreError> {
1273    let version: i64 = connection.query_row("PRAGMA user_version", [], |row| row.get(0))?;
1274    if !matches!(version, 0 | 1 | 2 | DATABASE_SCHEMA_VERSION) {
1275        return Err(StoreError::UnsupportedDatabaseSchema {
1276            path: path.to_path_buf(),
1277            found: version,
1278            expected: DATABASE_SCHEMA_VERSION,
1279        });
1280    }
1281
1282    let tx = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
1283    if version == 1 {
1284        tx.execute_batch(
1285            "ALTER TABLE tasks RENAME COLUMN body_json TO description_json;
1286             DROP INDEX IF EXISTS task_attachments_by_attachment;
1287             ALTER TABLE task_attachments RENAME TO task_description_attachments;
1288             CREATE INDEX task_description_attachments_by_attachment
1289                 ON task_description_attachments(attachment_id);",
1290        )?;
1291    }
1292    tx.execute_batch(
1293        "
1294        CREATE TABLE IF NOT EXISTS metadata (
1295            key TEXT PRIMARY KEY,
1296            value TEXT NOT NULL
1297        ) STRICT;
1298        CREATE TABLE IF NOT EXISTS app_state (
1299            id INTEGER PRIMARY KEY CHECK (id = 1),
1300            revision INTEGER NOT NULL CHECK (revision >= 0),
1301            settings_json TEXT NOT NULL
1302        ) STRICT;
1303        CREATE TABLE IF NOT EXISTS categories (
1304            id TEXT PRIMARY KEY,
1305            position INTEGER NOT NULL UNIQUE CHECK (position >= 0),
1306            name TEXT NOT NULL CHECK (length(trim(name)) > 0),
1307            name_key TEXT NOT NULL UNIQUE CHECK (length(name_key) > 0),
1308            description TEXT NOT NULL
1309        ) STRICT;
1310        CREATE TABLE IF NOT EXISTS tasks (
1311            id TEXT PRIMARY KEY,
1312            position INTEGER NOT NULL UNIQUE CHECK (position >= 0),
1313            title TEXT NOT NULL CHECK (length(trim(title)) > 0),
1314            description_json TEXT NOT NULL,
1315            due TEXT NOT NULL,
1316            created TEXT NOT NULL,
1317            done INTEGER NOT NULL CHECK (done IN (0, 1)),
1318            importance INTEGER NOT NULL CHECK (importance BETWEEN 0 AND 3),
1319            category_id TEXT REFERENCES categories(id) ON DELETE SET NULL
1320        ) STRICT;
1321        CREATE TABLE IF NOT EXISTS labels (
1322            id TEXT PRIMARY KEY,
1323            position INTEGER NOT NULL UNIQUE CHECK (position >= 0),
1324            name TEXT NOT NULL CHECK (length(trim(name)) > 0),
1325            name_key TEXT NOT NULL UNIQUE CHECK (length(name_key) > 0),
1326            color TEXT NOT NULL DEFAULT 'red'
1327                CHECK (color IN ('red', 'orange', 'yellow', 'lime', 'green', 'teal', 'cyan', 'blue', 'indigo', 'purple', 'pink', 'brown'))
1328        ) STRICT;
1329        CREATE TABLE IF NOT EXISTS task_labels (
1330            task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1331            label_id TEXT NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
1332            position INTEGER NOT NULL CHECK (position >= 0),
1333            PRIMARY KEY (task_id, label_id),
1334            UNIQUE (task_id, position)
1335        ) STRICT;
1336        CREATE TABLE IF NOT EXISTS attachments (
1337            id TEXT PRIMARY KEY,
1338            sha256 TEXT NOT NULL UNIQUE CHECK (sha256 = id),
1339            media_type TEXT NOT NULL,
1340            byte_len INTEGER NOT NULL CHECK (byte_len > 0),
1341            storage_name TEXT NOT NULL UNIQUE
1342        ) STRICT;
1343        CREATE TABLE IF NOT EXISTS task_description_attachments (
1344            task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1345            block_index INTEGER NOT NULL CHECK (block_index >= 0),
1346            attachment_id TEXT NOT NULL REFERENCES attachments(id) ON DELETE RESTRICT,
1347            PRIMARY KEY (task_id, block_index)
1348        ) STRICT;
1349        CREATE TABLE IF NOT EXISTS attachment_gc (
1350            storage_name TEXT PRIMARY KEY
1351        ) STRICT;
1352        CREATE INDEX IF NOT EXISTS task_description_attachments_by_attachment
1353            ON task_description_attachments(attachment_id);
1354        CREATE INDEX IF NOT EXISTS task_labels_by_label ON task_labels(label_id);
1355        ",
1356    )?;
1357    let settings = serde_json::to_string(&Settings::default()).map_err(|source| {
1358        StoreError::Corrupt(format!("could not encode default settings: {source}"))
1359    })?;
1360    tx.execute(
1361        "INSERT OR IGNORE INTO app_state(id, revision, settings_json) VALUES (1, 0, ?1)",
1362        [settings],
1363    )?;
1364    if version != DATABASE_SCHEMA_VERSION {
1365        tx.pragma_update(None, "user_version", DATABASE_SCHEMA_VERSION)?;
1366    }
1367    tx.commit()?;
1368    Ok(())
1369}
1370
1371pub(crate) fn sqlite_quick_check(
1372    connection: &Connection,
1373    error_prefix: &str,
1374) -> Result<(), StoreError> {
1375    let result: String = connection.query_row("PRAGMA quick_check(1)", [], |row| row.get(0))?;
1376    if result != "ok" {
1377        return Err(StoreError::Corrupt(format!(
1378            "{error_prefix}SQLite quick check failed: {result}"
1379        )));
1380    }
1381    Ok(())
1382}
1383
1384fn migration_complete(connection: &Connection) -> Result<bool, StoreError> {
1385    let value: Option<String> = connection
1386        .query_row(
1387            "SELECT value FROM metadata WHERE key = ?1",
1388            [LEGACY_MIGRATION_KEY],
1389            |row| row.get(0),
1390        )
1391        .optional()?;
1392    Ok(value.as_deref() == Some("1"))
1393}
1394
1395fn read_revision(connection: &Connection) -> Result<u64, StoreError> {
1396    let value: i64 =
1397        connection.query_row("SELECT revision FROM app_state WHERE id = 1", [], |row| {
1398            row.get(0)
1399        })?;
1400    u64::try_from(value).map_err(|_| StoreError::Corrupt(format!("negative revision {value}")))
1401}
1402
1403fn load_snapshot(connection: &Connection) -> Result<StoreData, StoreError> {
1404    let (revision, settings_json): (i64, String) = connection.query_row(
1405        "SELECT revision, settings_json FROM app_state WHERE id = 1",
1406        [],
1407        |row| Ok((row.get(0)?, row.get(1)?)),
1408    )?;
1409    let revision = u64::try_from(revision)
1410        .map_err(|_| StoreError::Corrupt(format!("negative revision {revision}")))?;
1411    let settings: Settings = serde_json::from_str(&settings_json)
1412        .map_err(|error| StoreError::Corrupt(format!("invalid settings JSON: {error}")))?;
1413
1414    let mut attachment_statement = connection.prepare(
1415        "SELECT id, sha256, media_type, byte_len, storage_name FROM attachments ORDER BY id",
1416    )?;
1417    let attachment_rows = attachment_statement.query_map([], |row| {
1418        Ok((
1419            row.get::<_, String>(0)?,
1420            row.get::<_, String>(1)?,
1421            row.get::<_, String>(2)?,
1422            row.get::<_, i64>(3)?,
1423            row.get::<_, String>(4)?,
1424        ))
1425    })?;
1426    let mut attachments = Vec::new();
1427    for row in attachment_rows {
1428        let (id, sha256, media_type, byte_len, storage_name) = row?;
1429        attachments.push(Attachment {
1430            id,
1431            sha256,
1432            media_type,
1433            byte_len: u64::try_from(byte_len).map_err(|_| {
1434                StoreError::Corrupt(format!("attachment has invalid byte length {byte_len}"))
1435            })?,
1436            storage_name,
1437        });
1438    }
1439
1440    let mut categories_statement = connection.prepare(
1441        "SELECT position, id, name, name_key, description FROM categories ORDER BY position",
1442    )?;
1443    let category_rows = categories_statement.query_map([], |row| {
1444        Ok((
1445            row.get::<_, i64>(0)?,
1446            Category {
1447                id: row.get(1)?,
1448                name: row.get(2)?,
1449                description: row.get(4)?,
1450            },
1451            row.get::<_, String>(3)?,
1452        ))
1453    })?;
1454    let mut categories = Vec::new();
1455    for (expected_position, row) in category_rows.enumerate() {
1456        if expected_position >= MAX_CATEGORY_COUNT {
1457            return Err(StoreError::Corrupt(format!(
1458                "category count exceeds {MAX_CATEGORY_COUNT}"
1459            )));
1460        }
1461        let (stored_position, category, stored_name_key) = row?;
1462        validate_stored_position(stored_position, expected_position, "category")?;
1463        let expected_name_key = category_name_key(&category.name);
1464        if stored_name_key != expected_name_key {
1465            return Err(StoreError::Corrupt(format!(
1466                "category {:?} has identity key {stored_name_key:?}, expected {expected_name_key:?}",
1467                category.id
1468            )));
1469        }
1470        categories.push(category);
1471    }
1472
1473    let mut labels_statement = connection
1474        .prepare("SELECT position, id, name, name_key, color FROM labels ORDER BY position")?;
1475    let label_rows = labels_statement.query_map([], |row| {
1476        Ok((
1477            row.get::<_, i64>(0)?,
1478            row.get::<_, String>(1)?,
1479            row.get::<_, String>(2)?,
1480            row.get::<_, String>(3)?,
1481            row.get::<_, String>(4)?,
1482        ))
1483    })?;
1484    let mut labels = Vec::new();
1485    for (expected_position, row) in label_rows.enumerate() {
1486        if expected_position >= MAX_LABEL_COUNT {
1487            return Err(StoreError::Corrupt(format!(
1488                "label count exceeds {MAX_LABEL_COUNT}"
1489            )));
1490        }
1491        let (stored_position, id, name, stored_name_key, stored_color) = row?;
1492        validate_stored_position(stored_position, expected_position, "label")?;
1493        let color = stored_color.parse::<LabelColor>().map_err(|_| {
1494            StoreError::Corrupt(format!("label {id:?} has unknown color {stored_color:?}"))
1495        })?;
1496        let label = Label { id, name, color };
1497        let expected_name_key = label_name_key(&label.name);
1498        if stored_name_key != expected_name_key {
1499            return Err(StoreError::Corrupt(format!(
1500                "label {:?} has identity key {stored_name_key:?}, expected {expected_name_key:?}",
1501                label.id
1502            )));
1503        }
1504        labels.push(label);
1505    }
1506
1507    let mut tasks_statement = connection.prepare(
1508        "SELECT position, id, title, description_json, due, created, done, importance, category_id
1509         FROM tasks ORDER BY position",
1510    )?;
1511    let rows = tasks_statement.query_map([], |row| {
1512        Ok((
1513            row.get::<_, i64>(0)?,
1514            row.get::<_, String>(1)?,
1515            row.get::<_, String>(2)?,
1516            row.get::<_, String>(3)?,
1517            row.get::<_, String>(4)?,
1518            row.get::<_, String>(5)?,
1519            row.get::<_, i64>(6)?,
1520            row.get::<_, i64>(7)?,
1521            row.get::<_, Option<String>>(8)?,
1522        ))
1523    })?;
1524    let mut tasks = Vec::new();
1525    for (expected_position, row) in rows.enumerate() {
1526        if expected_position >= MAX_TASK_COUNT {
1527            return Err(StoreError::Corrupt(format!(
1528                "task count exceeds {MAX_TASK_COUNT}"
1529            )));
1530        }
1531        let (
1532            stored_position,
1533            id,
1534            title,
1535            description_json,
1536            due,
1537            created,
1538            done,
1539            importance,
1540            category_id,
1541        ) = row?;
1542        validate_stored_position(stored_position, expected_position, "task")?;
1543        let description =
1544            serde_json::from_str::<Vec<Block>>(&description_json).map_err(|error| {
1545                StoreError::Corrupt(format!("task {id:?} has invalid description JSON: {error}"))
1546            })?;
1547        let importance = u8::try_from(importance).map_err(|_| {
1548            StoreError::Corrupt(format!("task {id:?} has invalid importance {importance}"))
1549        })?;
1550        tasks.push(Task {
1551            id,
1552            title,
1553            description,
1554            due,
1555            created,
1556            done: done != 0,
1557            importance,
1558            category_id,
1559            label_ids: Vec::new(),
1560        });
1561    }
1562    let task_indices: HashMap<_, _> = tasks
1563        .iter()
1564        .enumerate()
1565        .map(|(index, task)| (task.id.clone(), index))
1566        .collect();
1567    let mut expected_label_positions: HashMap<String, usize> = HashMap::new();
1568    let mut task_labels_statement = connection.prepare(
1569        "SELECT task_id, position, label_id FROM task_labels ORDER BY task_id, position",
1570    )?;
1571    let task_label_rows = task_labels_statement.query_map([], |row| {
1572        Ok((
1573            row.get::<_, String>(0)?,
1574            row.get::<_, i64>(1)?,
1575            row.get::<_, String>(2)?,
1576        ))
1577    })?;
1578    for row in task_label_rows {
1579        let (task_id, stored_position, label_id) = row?;
1580        let task_index = task_indices.get(&task_id).copied().ok_or_else(|| {
1581            StoreError::Corrupt(format!(
1582                "task label assignment refers to unknown task {task_id:?}"
1583            ))
1584        })?;
1585        let expected_position = expected_label_positions.entry(task_id.clone()).or_default();
1586        validate_stored_position(stored_position, *expected_position, "task label")?;
1587        *expected_position += 1;
1588        tasks[task_index].label_ids.push(label_id);
1589    }
1590    validate_task_attachment_rows(connection, &tasks)?;
1591    let mut data = StoreData {
1592        revision,
1593        categories,
1594        labels,
1595        tasks,
1596        settings,
1597        attachments,
1598    };
1599    data.validate_as_stored().map_err(|error| match error {
1600        StoreError::Validation(message) => StoreError::Corrupt(message),
1601        other => other,
1602    })?;
1603    Ok(data)
1604}
1605
1606fn validate_task_attachment_rows(
1607    connection: &Connection,
1608    tasks: &[Task],
1609) -> Result<(), StoreError> {
1610    let expected: HashSet<(String, usize, String)> = tasks
1611        .iter()
1612        .flat_map(|task| {
1613            task.description
1614                .iter()
1615                .enumerate()
1616                .filter_map(|(block_index, block)| match block {
1617                    Block::Image { attachment_id } => {
1618                        Some((task.id.clone(), block_index, attachment_id.clone()))
1619                    }
1620                    _ => None,
1621                })
1622        })
1623        .collect();
1624    let mut statement = connection.prepare(
1625        "SELECT task_id, block_index, attachment_id
1626         FROM task_description_attachments ORDER BY task_id, block_index",
1627    )?;
1628    let rows = statement.query_map([], |row| {
1629        Ok((
1630            row.get::<_, String>(0)?,
1631            row.get::<_, i64>(1)?,
1632            row.get::<_, String>(2)?,
1633        ))
1634    })?;
1635    let mut stored = HashSet::new();
1636    for row in rows {
1637        let (task_id, block_index, attachment_id) = row?;
1638        let block_index = usize::try_from(block_index).map_err(|_| {
1639            StoreError::Corrupt(format!(
1640                "task {task_id:?} has invalid attachment reference index {block_index}"
1641            ))
1642        })?;
1643        stored.insert((task_id, block_index, attachment_id));
1644    }
1645    if stored != expected {
1646        return Err(StoreError::Corrupt(
1647            "task attachment reference rows do not match task description JSON".into(),
1648        ));
1649    }
1650    Ok(())
1651}
1652
1653fn validate_stored_position(stored: i64, expected: usize, entity: &str) -> Result<(), StoreError> {
1654    let expected = i64::try_from(expected)
1655        .map_err(|_| StoreError::Corrupt(format!("{entity} position exceeds integer range")))?;
1656    if stored != expected {
1657        return Err(StoreError::Corrupt(format!(
1658            "{entity} position {stored} is not contiguous (expected {expected})"
1659        )));
1660    }
1661    Ok(())
1662}
1663
1664/// Persist only rows whose identity, content, or position changed.
1665///
1666/// Positions and category names have UNIQUE constraints. Rows that move or
1667/// change names are first assigned transaction-private values outside the
1668/// validated application domain, which makes swaps and insertions safe without
1669/// deleting and recreating unrelated rows.
1670fn persist_diff(
1671    tx: &Transaction<'_>,
1672    before: &StoreData,
1673    after: &StoreData,
1674) -> Result<(), StoreError> {
1675    let before_categories: HashMap<&str, (usize, &Category)> = before
1676        .categories
1677        .iter()
1678        .enumerate()
1679        .map(|(position, category)| (category.id.as_str(), (position, category)))
1680        .collect();
1681    let after_categories: HashMap<&str, (usize, &Category)> = after
1682        .categories
1683        .iter()
1684        .enumerate()
1685        .map(|(position, category)| (category.id.as_str(), (position, category)))
1686        .collect();
1687    let before_labels: HashMap<&str, (usize, &Label)> = before
1688        .labels
1689        .iter()
1690        .enumerate()
1691        .map(|(position, label)| (label.id.as_str(), (position, label)))
1692        .collect();
1693    let after_labels: HashMap<&str, (usize, &Label)> = after
1694        .labels
1695        .iter()
1696        .enumerate()
1697        .map(|(position, label)| (label.id.as_str(), (position, label)))
1698        .collect();
1699    let before_tasks: HashMap<&str, (usize, &Task)> = before
1700        .tasks
1701        .iter()
1702        .enumerate()
1703        .map(|(position, task)| (task.id.as_str(), (position, task)))
1704        .collect();
1705    let after_tasks: HashMap<&str, (usize, &Task)> = after
1706        .tasks
1707        .iter()
1708        .enumerate()
1709        .map(|(position, task)| (task.id.as_str(), (position, task)))
1710        .collect();
1711    let before_attachments: HashMap<&str, &Attachment> = before
1712        .attachments
1713        .iter()
1714        .map(|attachment| (attachment.id.as_str(), attachment))
1715        .collect();
1716    let after_attachments: HashMap<&str, &Attachment> = after
1717        .attachments
1718        .iter()
1719        .map(|attachment| (attachment.id.as_str(), attachment))
1720        .collect();
1721
1722    for attachment in &before.attachments {
1723        if after_attachments
1724            .get(attachment.id.as_str())
1725            .is_some_and(|current| *current != attachment)
1726        {
1727            return Err(StoreError::Validation(format!(
1728                "attachment {:?} metadata is immutable",
1729                attachment.id
1730            )));
1731        }
1732    }
1733    for attachment in &after.attachments {
1734        if !before_attachments.contains_key(attachment.id.as_str()) {
1735            tx.execute(
1736                "INSERT INTO attachments(id, sha256, media_type, byte_len, storage_name)
1737                 VALUES (?1, ?2, ?3, ?4, ?5)",
1738                params![
1739                    attachment.id,
1740                    attachment.sha256,
1741                    attachment.media_type,
1742                    sqlite_attachment_size(attachment.byte_len)?,
1743                    attachment.storage_name,
1744                ],
1745            )?;
1746            tx.execute(
1747                "DELETE FROM attachment_gc WHERE storage_name = ?1",
1748                [&attachment.storage_name],
1749            )?;
1750        }
1751    }
1752
1753    // Remove tasks first so deleting a task and its category does not produce
1754    // an unnecessary ON DELETE SET NULL update.
1755    for task in &before.tasks {
1756        if !after_tasks.contains_key(task.id.as_str()) {
1757            execute_one(
1758                tx,
1759                "DELETE FROM tasks WHERE id = ?1",
1760                [task.id.as_str()],
1761                "task",
1762                &task.id,
1763            )?;
1764        }
1765    }
1766
1767    // Free every old identity key that may be replaced. Control characters are
1768    // rejected by validation, so these temporary values cannot collide with
1769    // application data and are never visible outside this transaction.
1770    let mut temporary_name_index = 0usize;
1771    for category in &before.categories {
1772        let name_changed_or_removed = after_categories
1773            .get(category.id.as_str())
1774            .is_none_or(|(_, current)| current.name != category.name);
1775        if name_changed_or_removed {
1776            let temporary_name = format!("\u{1f}mach-category-{temporary_name_index}");
1777            temporary_name_index += 1;
1778            execute_one(
1779                tx,
1780                "UPDATE categories SET name = ?1, name_key = ?1 WHERE id = ?2",
1781                params![temporary_name, category.id],
1782                "category",
1783                &category.id,
1784            )?;
1785        }
1786    }
1787    let mut temporary_label_name_index = 0usize;
1788    for label in &before.labels {
1789        let name_changed_or_removed = after_labels
1790            .get(label.id.as_str())
1791            .is_none_or(|(_, current)| current.name != label.name);
1792        if name_changed_or_removed {
1793            let temporary_name = format!("\u{1f}mach-label-{temporary_label_name_index}");
1794            temporary_label_name_index += 1;
1795            execute_one(
1796                tx,
1797                "UPDATE labels SET name = ?1, name_key = ?1 WHERE id = ?2",
1798                params![temporary_name, label.id],
1799                "label",
1800                &label.id,
1801            )?;
1802        }
1803    }
1804
1805    let category_position_base = before.categories.len().max(after.categories.len());
1806    let mut category_position_offset = 0usize;
1807    for (old_position, category) in before.categories.iter().enumerate() {
1808        if let Some((new_position, _)) = after_categories.get(category.id.as_str()).copied()
1809            && new_position != old_position
1810        {
1811            let temporary = temporary_position(
1812                category_position_base,
1813                category_position_offset,
1814                "categories",
1815            )?;
1816            category_position_offset += 1;
1817            execute_one(
1818                tx,
1819                "UPDATE categories SET position = ?1 WHERE id = ?2",
1820                params![temporary, category.id],
1821                "category",
1822                &category.id,
1823            )?;
1824        }
1825    }
1826    for category in &after.categories {
1827        if !before_categories.contains_key(category.id.as_str()) {
1828            let temporary = temporary_position(
1829                category_position_base,
1830                category_position_offset,
1831                "categories",
1832            )?;
1833            category_position_offset += 1;
1834            tx.execute(
1835                "INSERT INTO categories(id, position, name, name_key, description)
1836                 VALUES (?1, ?2, ?3, ?4, ?5)",
1837                params![
1838                    category.id,
1839                    temporary,
1840                    category.name,
1841                    category_name_key(&category.name),
1842                    category.description
1843                ],
1844            )?;
1845        }
1846    }
1847
1848    let label_position_base = before.labels.len().max(after.labels.len());
1849    let mut label_position_offset = 0usize;
1850    for (old_position, label) in before.labels.iter().enumerate() {
1851        if let Some((new_position, _)) = after_labels.get(label.id.as_str()).copied()
1852            && new_position != old_position
1853        {
1854            let temporary =
1855                temporary_position(label_position_base, label_position_offset, "labels")?;
1856            label_position_offset += 1;
1857            execute_one(
1858                tx,
1859                "UPDATE labels SET position = ?1 WHERE id = ?2",
1860                params![temporary, label.id],
1861                "label",
1862                &label.id,
1863            )?;
1864        }
1865    }
1866    for label in &after.labels {
1867        if !before_labels.contains_key(label.id.as_str()) {
1868            let temporary =
1869                temporary_position(label_position_base, label_position_offset, "labels")?;
1870            label_position_offset += 1;
1871            tx.execute(
1872                "INSERT INTO labels(id, position, name, name_key, color)
1873                 VALUES (?1, ?2, ?3, ?4, ?5)",
1874                params![
1875                    label.id,
1876                    temporary,
1877                    label.name,
1878                    label_name_key(&label.name),
1879                    label.color.as_str(),
1880                ],
1881            )?;
1882        }
1883    }
1884
1885    let task_position_base = before.tasks.len().max(after.tasks.len());
1886    let mut task_position_offset = 0usize;
1887    for (old_position, task) in before.tasks.iter().enumerate() {
1888        if let Some((new_position, _)) = after_tasks.get(task.id.as_str()).copied()
1889            && new_position != old_position
1890        {
1891            let temporary = temporary_position(task_position_base, task_position_offset, "tasks")?;
1892            task_position_offset += 1;
1893            execute_one(
1894                tx,
1895                "UPDATE tasks SET position = ?1 WHERE id = ?2",
1896                params![temporary, task.id],
1897                "task",
1898                &task.id,
1899            )?;
1900        }
1901    }
1902
1903    // New categories now exist, so task foreign keys can safely move to them
1904    // before obsolete categories are deleted.
1905    for task in &after.tasks {
1906        if let Some((_, previous)) = before_tasks.get(task.id.as_str()).copied()
1907            && !task_row_equal(previous, task)
1908        {
1909            if previous.description == task.description {
1910                execute_one(
1911                    tx,
1912                    "UPDATE tasks SET
1913                        title = ?1, due = ?2, created = ?3, done = ?4,
1914                        importance = ?5, category_id = ?6
1915                     WHERE id = ?7",
1916                    params![
1917                        task.title,
1918                        task.due,
1919                        task.created,
1920                        i64::from(task.done),
1921                        i64::from(task.importance),
1922                        task.category_id,
1923                        task.id,
1924                    ],
1925                    "task",
1926                    &task.id,
1927                )?;
1928            } else {
1929                let description_json = encode_task_description(task)?;
1930                execute_one(
1931                    tx,
1932                    "UPDATE tasks SET
1933                        title = ?1, description_json = ?2, due = ?3, created = ?4,
1934                        done = ?5, importance = ?6, category_id = ?7
1935                     WHERE id = ?8",
1936                    params![
1937                        task.title,
1938                        description_json,
1939                        task.due,
1940                        task.created,
1941                        i64::from(task.done),
1942                        i64::from(task.importance),
1943                        task.category_id,
1944                        task.id,
1945                    ],
1946                    "task",
1947                    &task.id,
1948                )?;
1949            }
1950        }
1951    }
1952    for task in &after.tasks {
1953        if !before_tasks.contains_key(task.id.as_str()) {
1954            let temporary = temporary_position(task_position_base, task_position_offset, "tasks")?;
1955            task_position_offset += 1;
1956            let description_json = encode_task_description(task)?;
1957            tx.execute(
1958                "INSERT INTO tasks(
1959                    id, position, title, description_json, due, created, done, importance, category_id
1960                 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1961                params![
1962                    task.id,
1963                    temporary,
1964                    task.title,
1965                    description_json,
1966                    task.due,
1967                    task.created,
1968                    i64::from(task.done),
1969                    i64::from(task.importance),
1970                    task.category_id,
1971                ],
1972            )?;
1973        }
1974    }
1975
1976    for task in &after.tasks {
1977        let description_changed_or_new = before_tasks
1978            .get(task.id.as_str())
1979            .is_none_or(|(_, previous)| previous.description != task.description);
1980        if description_changed_or_new {
1981            tx.execute(
1982                "DELETE FROM task_description_attachments WHERE task_id = ?1",
1983                [&task.id],
1984            )?;
1985            insert_task_attachment_rows(tx, task)?;
1986        }
1987    }
1988    for task in &after.tasks {
1989        let labels_changed_or_new = before_tasks
1990            .get(task.id.as_str())
1991            .is_none_or(|(_, previous)| previous.label_ids != task.label_ids);
1992        if labels_changed_or_new {
1993            tx.execute("DELETE FROM task_labels WHERE task_id = ?1", [&task.id])?;
1994            insert_task_label_rows(tx, task)?;
1995        }
1996    }
1997
1998    for attachment in &before.attachments {
1999        if !after_attachments.contains_key(attachment.id.as_str()) {
2000            tx.execute(
2001                "INSERT OR IGNORE INTO attachment_gc(storage_name) VALUES (?1)",
2002                [&attachment.storage_name],
2003            )?;
2004            execute_one(
2005                tx,
2006                "DELETE FROM attachments WHERE id = ?1",
2007                [attachment.id.as_str()],
2008                "attachment",
2009                &attachment.id,
2010            )?;
2011        }
2012    }
2013
2014    for category in &before.categories {
2015        if !after_categories.contains_key(category.id.as_str()) {
2016            execute_one(
2017                tx,
2018                "DELETE FROM categories WHERE id = ?1",
2019                [category.id.as_str()],
2020                "category",
2021                &category.id,
2022            )?;
2023        }
2024    }
2025
2026    for label in &before.labels {
2027        if !after_labels.contains_key(label.id.as_str()) {
2028            execute_one(
2029                tx,
2030                "DELETE FROM labels WHERE id = ?1",
2031                [label.id.as_str()],
2032                "label",
2033                &label.id,
2034            )?;
2035        }
2036    }
2037
2038    for category in &after.categories {
2039        if let Some((_, previous)) = before_categories.get(category.id.as_str()).copied()
2040            && previous != category
2041        {
2042            execute_one(
2043                tx,
2044                "UPDATE categories
2045                 SET name = ?1, name_key = ?2, description = ?3
2046                 WHERE id = ?4",
2047                params![
2048                    category.name,
2049                    category_name_key(&category.name),
2050                    category.description,
2051                    category.id
2052                ],
2053                "category",
2054                &category.id,
2055            )?;
2056        }
2057    }
2058    for label in &after.labels {
2059        if let Some((_, previous)) = before_labels.get(label.id.as_str()).copied()
2060            && previous != label
2061        {
2062            execute_one(
2063                tx,
2064                "UPDATE labels SET name = ?1, name_key = ?2, color = ?3 WHERE id = ?4",
2065                params![
2066                    label.name,
2067                    label_name_key(&label.name),
2068                    label.color.as_str(),
2069                    label.id,
2070                ],
2071                "label",
2072                &label.id,
2073            )?;
2074        }
2075    }
2076
2077    // All rows whose final slots changed are currently at unique temporary
2078    // positions. Rows omitted here kept the same slot, so final assignment
2079    // cannot collide with them.
2080    for (position, category) in after.categories.iter().enumerate() {
2081        let moved_or_new = before_categories
2082            .get(category.id.as_str())
2083            .is_none_or(|(old_position, _)| *old_position != position);
2084        if moved_or_new {
2085            let position = sqlite_position(position, "categories")?;
2086            execute_one(
2087                tx,
2088                "UPDATE categories SET position = ?1 WHERE id = ?2",
2089                params![position, category.id],
2090                "category",
2091                &category.id,
2092            )?;
2093        }
2094    }
2095    for (position, label) in after.labels.iter().enumerate() {
2096        let moved_or_new = before_labels
2097            .get(label.id.as_str())
2098            .is_none_or(|(old_position, _)| *old_position != position);
2099        if moved_or_new {
2100            let position = sqlite_position(position, "labels")?;
2101            execute_one(
2102                tx,
2103                "UPDATE labels SET position = ?1 WHERE id = ?2",
2104                params![position, label.id],
2105                "label",
2106                &label.id,
2107            )?;
2108        }
2109    }
2110    for (position, task) in after.tasks.iter().enumerate() {
2111        let moved_or_new = before_tasks
2112            .get(task.id.as_str())
2113            .is_none_or(|(old_position, _)| *old_position != position);
2114        if moved_or_new {
2115            let position = sqlite_position(position, "tasks")?;
2116            execute_one(
2117                tx,
2118                "UPDATE tasks SET position = ?1 WHERE id = ?2",
2119                params![position, task.id],
2120                "task",
2121                &task.id,
2122            )?;
2123        }
2124    }
2125
2126    let settings = (before.settings != after.settings).then_some(&after.settings);
2127    persist_app_state(tx, after.revision, settings)?;
2128    Ok(())
2129}
2130
2131fn task_row_equal(left: &Task, right: &Task) -> bool {
2132    left.id == right.id
2133        && left.title == right.title
2134        && left.description == right.description
2135        && left.due == right.due
2136        && left.created == right.created
2137        && left.done == right.done
2138        && left.importance == right.importance
2139        && left.category_id == right.category_id
2140}
2141
2142fn execute_one<P: rusqlite::Params>(
2143    tx: &Transaction<'_>,
2144    sql: &str,
2145    params: P,
2146    entity: &str,
2147    id: &str,
2148) -> Result<(), StoreError> {
2149    let changed = tx.execute(sql, params)?;
2150    if changed != 1 {
2151        return Err(StoreError::Corrupt(format!(
2152            "expected to change one {entity} {id:?}, changed {changed}"
2153        )));
2154    }
2155    Ok(())
2156}
2157
2158fn temporary_position(base: usize, offset: usize, entity: &str) -> Result<i64, StoreError> {
2159    let position = base
2160        .checked_add(offset)
2161        .ok_or_else(|| StoreError::Validation(format!("too many {entity}")))?;
2162    sqlite_position(position, entity)
2163}
2164
2165fn sqlite_position(position: usize, entity: &str) -> Result<i64, StoreError> {
2166    i64::try_from(position).map_err(|_| StoreError::Validation(format!("too many {entity}")))
2167}
2168
2169fn sqlite_attachment_size(byte_len: u64) -> Result<i64, StoreError> {
2170    i64::try_from(byte_len)
2171        .map_err(|_| StoreError::Validation("attachment byte length exceeds integer range".into()))
2172}
2173
2174fn insert_task_attachment_rows(tx: &Transaction<'_>, task: &Task) -> Result<(), StoreError> {
2175    let mut statement = tx.prepare(
2176        "INSERT INTO task_description_attachments(task_id, block_index, attachment_id)
2177         VALUES (?1, ?2, ?3)",
2178    )?;
2179    for (block_index, block) in task.description.iter().enumerate() {
2180        let Block::Image { attachment_id } = block else {
2181            continue;
2182        };
2183        statement.execute(params![
2184            task.id,
2185            sqlite_position(block_index, "task attachment blocks")?,
2186            attachment_id,
2187        ])?;
2188    }
2189    Ok(())
2190}
2191
2192fn insert_task_label_rows(tx: &Transaction<'_>, task: &Task) -> Result<(), StoreError> {
2193    let mut statement =
2194        tx.prepare("INSERT INTO task_labels(task_id, label_id, position) VALUES (?1, ?2, ?3)")?;
2195    for (position, label_id) in task.label_ids.iter().enumerate() {
2196        statement.execute(params![
2197            task.id,
2198            label_id,
2199            sqlite_position(position, "task labels")?,
2200        ])?;
2201    }
2202    Ok(())
2203}
2204
2205fn encode_task_description(task: &Task) -> Result<String, StoreError> {
2206    serde_json::to_string(&task.description).map_err(|error| {
2207        StoreError::Corrupt(format!("could not encode task {:?}: {error}", task.id))
2208    })
2209}
2210
2211fn persist_app_state(
2212    tx: &Transaction<'_>,
2213    revision: u64,
2214    settings: Option<&Settings>,
2215) -> Result<(), StoreError> {
2216    let revision = i64::try_from(revision)
2217        .map_err(|_| StoreError::Corrupt("revision exceeds SQLite integer range".into()))?;
2218    let changed = if let Some(settings) = settings {
2219        let settings_json = serde_json::to_string(settings)
2220            .map_err(|error| StoreError::Corrupt(format!("could not encode settings: {error}")))?;
2221        tx.execute(
2222            "UPDATE app_state SET revision = ?1, settings_json = ?2 WHERE id = 1",
2223            params![revision, settings_json],
2224        )?
2225    } else {
2226        tx.execute(
2227            "UPDATE app_state SET revision = ?1 WHERE id = 1",
2228            [revision],
2229        )?
2230    };
2231    if changed != 1 {
2232        return Err(StoreError::Corrupt(format!(
2233            "expected to update app state, changed {changed} rows"
2234        )));
2235    }
2236    Ok(())
2237}
2238
2239fn import_task_description_attachments(
2240    data: &mut StoreData,
2241    images_root: Option<&Path>,
2242    staged_attachments: &[StagedAttachment],
2243    installed_attachments: &mut Vec<InstalledAttachmentFile>,
2244) -> Result<(), StoreError> {
2245    let mut known: HashMap<String, usize> = data
2246        .attachments
2247        .iter()
2248        .enumerate()
2249        .map(|(index, attachment)| (attachment.id.clone(), index))
2250        .collect();
2251    let mut staged_by_id = HashMap::with_capacity(staged_attachments.len());
2252    for staged in staged_attachments {
2253        if staged_by_id
2254            .insert(staged.metadata.id.as_str(), staged)
2255            .is_some()
2256        {
2257            return Err(StoreError::Validation(format!(
2258                "staged attachment {:?} is duplicated",
2259                staged.metadata.id
2260            )));
2261        }
2262    }
2263
2264    for task in &mut data.tasks {
2265        let mut owner = None;
2266        for block in &mut task.description {
2267            let Block::Image { attachment_id } = block else {
2268                continue;
2269            };
2270            if known.contains_key(attachment_id) {
2271                continue;
2272            }
2273            let owner = owner.get_or_insert_with(|| format!("task {:?}", task.id));
2274            *attachment_id = import_attachment_reference(
2275                attachment_id,
2276                owner,
2277                images_root,
2278                &staged_by_id,
2279                &mut known,
2280                &mut data.attachments,
2281                installed_attachments,
2282            )?;
2283        }
2284    }
2285    data.attachments
2286        .sort_by(|left, right| left.id.cmp(&right.id));
2287    Ok(())
2288}
2289
2290fn import_attachment_reference(
2291    reference: &str,
2292    owner: &str,
2293    images_root: Option<&Path>,
2294    staged_by_id: &HashMap<&str, &StagedAttachment>,
2295    known: &mut HashMap<String, usize>,
2296    attachments: &mut Vec<Attachment>,
2297    installed_attachments: &mut Vec<InstalledAttachmentFile>,
2298) -> Result<String, StoreError> {
2299    let Some(images_root) = images_root else {
2300        return Err(StoreError::Validation(
2301            "image attachments require a persistent store".into(),
2302        ));
2303    };
2304    let (source_path, expected) = if is_attachment_id(reference) {
2305        let staged = staged_by_id.get(reference).ok_or_else(|| {
2306            StoreError::Validation(format!(
2307                "{owner} refers to unknown attachment {reference:?}"
2308            ))
2309        })?;
2310        (staged.path.clone(), Some(&staged.metadata))
2311    } else {
2312        (crate::image::expand_in(reference, images_root), None)
2313    };
2314    let ImportedAttachmentFile {
2315        metadata,
2316        installed,
2317    } = import_attachment_from_path(&source_path, images_root)?;
2318    if let Some(installed) = installed {
2319        installed_attachments.push(installed);
2320    }
2321    if expected.is_some_and(|expected| expected != &metadata) {
2322        return Err(StoreError::Validation(format!(
2323            "staged attachment {reference:?} does not match its verified metadata"
2324        )));
2325    }
2326    if let Some(&index) = known.get(&metadata.id) {
2327        if attachments[index] != metadata {
2328            return Err(StoreError::Corrupt(format!(
2329                "attachment {:?} metadata does not match imported content",
2330                metadata.id
2331            )));
2332        }
2333        return Ok(metadata.id);
2334    }
2335    let id = metadata.id.clone();
2336    known.insert(id.clone(), attachments.len());
2337    attachments.push(metadata);
2338    Ok(id)
2339}
2340
2341#[derive(Debug, Clone)]
2342struct InstalledAttachmentFile {
2343    id: String,
2344    path: PathBuf,
2345}
2346
2347struct ImportedAttachmentFile {
2348    metadata: Attachment,
2349    installed: Option<InstalledAttachmentFile>,
2350}
2351
2352fn import_attachment_from_path(
2353    source_path: &Path,
2354    images_root: &Path,
2355) -> Result<ImportedAttachmentFile, StoreError> {
2356    let mut source = fs::File::open(source_path)
2357        .map_err(|error| StoreError::io("open image attachment", source_path, error))?;
2358    let metadata = source
2359        .metadata()
2360        .map_err(|error| StoreError::io("inspect image attachment", source_path, error))?;
2361    if !metadata.is_file() {
2362        return Err(StoreError::Validation(format!(
2363            "image attachment {} is not a regular file",
2364            source_path.display()
2365        )));
2366    }
2367
2368    ensure_private_directory(images_root)?;
2369    let temp_path = images_root.join(format!(".mach-attachment-{}.tmp", uuid::Uuid::new_v4()));
2370    let mut temp = open_private_attachment_temp(&temp_path)?;
2371    let mut installed_path = None;
2372    let result = (|| {
2373        let mut hasher = Sha256::new();
2374        let mut byte_len = 0_u64;
2375        let mut prefix = [0_u8; 32];
2376        let mut prefix_len = 0usize;
2377        let mut buffer = [0_u8; 64 * 1024];
2378        loop {
2379            let read = source
2380                .read(&mut buffer)
2381                .map_err(|error| StoreError::io("read image attachment", source_path, error))?;
2382            if read == 0 {
2383                break;
2384            }
2385            byte_len = byte_len
2386                .checked_add(read as u64)
2387                .ok_or_else(|| StoreError::Validation("image attachment is too large".into()))?;
2388            if byte_len > MAX_ATTACHMENT_BYTES {
2389                return Err(StoreError::Validation(format!(
2390                    "image attachment {} exceeds the {} MiB safety limit",
2391                    source_path.display(),
2392                    MAX_ATTACHMENT_BYTES / 1024 / 1024
2393                )));
2394            }
2395            if prefix_len < prefix.len() {
2396                let copy = (prefix.len() - prefix_len).min(read);
2397                prefix[prefix_len..prefix_len + copy].copy_from_slice(&buffer[..copy]);
2398                prefix_len += copy;
2399            }
2400            hasher.update(&buffer[..read]);
2401            temp.write_all(&buffer[..read]).map_err(|error| {
2402                StoreError::io("write managed image attachment", &temp_path, error)
2403            })?;
2404        }
2405        if byte_len == 0 {
2406            return Err(StoreError::Validation(format!(
2407                "image attachment {} is empty",
2408                source_path.display()
2409            )));
2410        }
2411        let format = image::guess_format(&prefix[..prefix_len]).map_err(|_| {
2412            StoreError::Validation(format!(
2413                "image attachment {} is not a supported PNG, JPEG, GIF, or WebP image",
2414                source_path.display()
2415            ))
2416        })?;
2417        let format = crate::image::managed_attachment_format(format).ok_or_else(|| {
2418            StoreError::Validation(format!(
2419                "image attachment {} is not a supported PNG, JPEG, GIF, or WebP image",
2420                source_path.display()
2421            ))
2422        })?;
2423        temp.sync_all()
2424            .map_err(|error| StoreError::io("sync managed image attachment", &temp_path, error))?;
2425        drop(temp);
2426        crate::image::load_dynamic(&temp_path).map_err(StoreError::Validation)?;
2427
2428        let id = format!("{:x}", hasher.finalize());
2429        let storage_name = format!("{id}.{}", format.extension);
2430        let destination = images_root.join(&storage_name);
2431        if destination.exists() {
2432            let (stored_hash, stored_len) = hash_attachment_file(&destination)?;
2433            if stored_hash != id || stored_len != byte_len {
2434                return Err(StoreError::Corrupt(format!(
2435                    "managed attachment {} does not match its content address",
2436                    destination.display()
2437                )));
2438            }
2439            fs::remove_file(&temp_path).map_err(|error| {
2440                StoreError::io("remove duplicate image attachment", &temp_path, error)
2441            })?;
2442        } else {
2443            fs::rename(&temp_path, &destination).map_err(|error| {
2444                StoreError::io("install managed image attachment", &destination, error)
2445            })?;
2446            installed_path = Some(destination.clone());
2447            set_private_file(&destination)?;
2448            fs::File::open(images_root)
2449                .and_then(|directory| directory.sync_all())
2450                .map_err(|error| StoreError::io("sync image directory", images_root, error))?;
2451        }
2452        Ok(Attachment {
2453            id: id.clone(),
2454            sha256: id,
2455            media_type: format.media_type.into(),
2456            byte_len,
2457            storage_name,
2458        })
2459    })();
2460    if result.is_err() {
2461        let _ = fs::remove_file(&temp_path);
2462        if let Some(path) = installed_path.as_deref() {
2463            let _ = fs::remove_file(path);
2464        }
2465    }
2466    let metadata = result?;
2467    let installed = installed_path.map(|path| InstalledAttachmentFile {
2468        id: metadata.id.clone(),
2469        path,
2470    });
2471    Ok(ImportedAttachmentFile {
2472        metadata,
2473        installed,
2474    })
2475}
2476
2477fn open_private_attachment_temp(path: &Path) -> Result<fs::File, StoreError> {
2478    let mut options = fs::OpenOptions::new();
2479    options.write(true).create_new(true);
2480    #[cfg(unix)]
2481    {
2482        use std::os::unix::fs::OpenOptionsExt;
2483        options.mode(0o600);
2484    }
2485    options
2486        .open(path)
2487        .map_err(|error| StoreError::io("create managed image attachment", path, error))
2488}
2489
2490fn hash_attachment_file(path: &Path) -> Result<(String, u64), StoreError> {
2491    let mut file = fs::File::open(path)
2492        .map_err(|error| StoreError::io("open managed image attachment", path, error))?;
2493    let mut hasher = Sha256::new();
2494    let mut byte_len = 0_u64;
2495    let mut buffer = [0_u8; 64 * 1024];
2496    loop {
2497        let read = file
2498            .read(&mut buffer)
2499            .map_err(|error| StoreError::io("read managed image attachment", path, error))?;
2500        if read == 0 {
2501            break;
2502        }
2503        byte_len = byte_len
2504            .checked_add(read as u64)
2505            .ok_or_else(|| StoreError::Corrupt("managed attachment is too large".into()))?;
2506        if byte_len > MAX_ATTACHMENT_BYTES {
2507            return Err(StoreError::Corrupt(format!(
2508                "managed attachment {} exceeds the safety limit",
2509                path.display()
2510            )));
2511        }
2512        hasher.update(&buffer[..read]);
2513    }
2514    Ok((format!("{:x}", hasher.finalize()), byte_len))
2515}
2516
2517pub(crate) fn is_attachment_id(value: &str) -> bool {
2518    value.len() == ATTACHMENT_ID_LEN
2519        && value
2520            .bytes()
2521            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
2522}
2523
2524pub(crate) fn is_managed_attachment_name(value: &str) -> bool {
2525    let Some((id, extension)) = value.rsplit_once('.') else {
2526        return false;
2527    };
2528    is_attachment_id(id) && crate::image::is_managed_attachment_extension(extension)
2529}
2530
2531fn is_managed_attachment_temp_name(value: &str) -> bool {
2532    value
2533        .strip_prefix(".mach-attachment-")
2534        .and_then(|value| value.strip_suffix(".tmp"))
2535        .is_some_and(|id| uuid::Uuid::parse_str(id).is_ok())
2536}
2537
2538fn prune_unreferenced_attachments(data: &mut StoreData) {
2539    let referenced: HashSet<_> = data
2540        .tasks
2541        .iter()
2542        .flat_map(|task| {
2543            task.description.iter().filter_map(|block| match block {
2544                Block::Image { attachment_id } => Some(attachment_id.as_str()),
2545                _ => None,
2546            })
2547        })
2548        .collect();
2549    data.attachments
2550        .retain(|attachment| referenced.contains(attachment.id.as_str()));
2551}
2552
2553fn remove_managed_attachment_file(path: &Path) -> Result<bool, StoreError> {
2554    match fs::remove_file(path) {
2555        Ok(()) => Ok(true),
2556        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
2557        Err(error) => Err(StoreError::io(
2558            "remove managed image attachment",
2559            path,
2560            error,
2561        )),
2562    }
2563}
2564
2565fn sync_images_directory(images_root: &Path) -> Result<(), StoreError> {
2566    if !images_root.exists() {
2567        return Ok(());
2568    }
2569    fs::File::open(images_root)
2570        .and_then(|directory| directory.sync_all())
2571        .map_err(|error| StoreError::io("sync image directory", images_root, error))
2572}
2573
2574fn remove_installed_attachment_files(
2575    installed_attachments: &[InstalledAttachmentFile],
2576) -> Result<(), StoreError> {
2577    let mut directory = None;
2578    let mut removed = false;
2579    for attachment in installed_attachments {
2580        removed |= remove_managed_attachment_file(&attachment.path)?;
2581        directory = attachment.path.parent();
2582    }
2583    if removed && let Some(directory) = directory {
2584        sync_images_directory(directory)?;
2585    }
2586    Ok(())
2587}
2588
2589fn cleanup_attachments_after_failed_commit(
2590    connection: &mut Connection,
2591    images_root: &Path,
2592    installed_attachments: &[InstalledAttachmentFile],
2593) -> Result<(), StoreError> {
2594    if installed_attachments.is_empty() {
2595        return Ok(());
2596    }
2597    let tx = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
2598    let mut unowned = Vec::new();
2599    for attachment in installed_attachments {
2600        let owned: bool = tx.query_row(
2601            "SELECT EXISTS(SELECT 1 FROM attachments WHERE id = ?1)",
2602            [&attachment.id],
2603            |row| row.get(0),
2604        )?;
2605        if !owned {
2606            unowned.push(attachment.clone());
2607        }
2608    }
2609    remove_installed_attachment_files(&unowned)?;
2610    tx.commit()?;
2611    sync_images_directory(images_root)?;
2612    Ok(())
2613}
2614
2615fn cleanup_pending_attachment_files(
2616    connection: &mut Connection,
2617    images_root: &Path,
2618) -> Result<(), StoreError> {
2619    let tx = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
2620    let pending = {
2621        let mut statement =
2622            tx.prepare("SELECT storage_name FROM attachment_gc ORDER BY storage_name")?;
2623        let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
2624        rows.collect::<Result<Vec<_>, _>>()?
2625    };
2626    let mut removed = false;
2627    for storage_name in pending {
2628        if !is_managed_attachment_name(&storage_name) {
2629            return Err(StoreError::Corrupt(format!(
2630                "attachment cleanup entry has invalid storage name {storage_name:?}"
2631            )));
2632        }
2633        let owned: bool = tx.query_row(
2634            "SELECT EXISTS(SELECT 1 FROM attachments WHERE storage_name = ?1)",
2635            [&storage_name],
2636            |row| row.get(0),
2637        )?;
2638        if !owned {
2639            removed |= remove_managed_attachment_file(&images_root.join(&storage_name))?;
2640        }
2641        tx.execute(
2642            "DELETE FROM attachment_gc WHERE storage_name = ?1",
2643            [&storage_name],
2644        )?;
2645    }
2646    if removed {
2647        sync_images_directory(images_root)?;
2648    }
2649    tx.commit()?;
2650    Ok(())
2651}
2652
2653fn reconcile_attachment_files(
2654    connection: &mut Connection,
2655    images_root: &Path,
2656) -> Result<(), StoreError> {
2657    let tx = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
2658    let unreferenced = {
2659        let mut statement = tx.prepare(
2660            "SELECT attachments.id, attachments.storage_name
2661             FROM attachments
2662             WHERE NOT EXISTS (
2663                 SELECT 1 FROM task_description_attachments
2664                 WHERE task_description_attachments.attachment_id = attachments.id
2665             )
2666             ORDER BY attachments.id",
2667        )?;
2668        let rows = statement.query_map([], |row| {
2669            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2670        })?;
2671        rows.collect::<Result<Vec<_>, _>>()?
2672    };
2673    for (id, storage_name) in unreferenced {
2674        if !is_managed_attachment_name(&storage_name)
2675            || !storage_name.starts_with(&format!("{id}."))
2676        {
2677            return Err(StoreError::Corrupt(format!(
2678                "attachment {id:?} has invalid storage name {storage_name:?}"
2679            )));
2680        }
2681        tx.execute(
2682            "INSERT OR IGNORE INTO attachment_gc(storage_name) VALUES (?1)",
2683            [&storage_name],
2684        )?;
2685        tx.execute("DELETE FROM attachments WHERE id = ?1", [&id])?;
2686    }
2687
2688    let owned = {
2689        let mut statement = tx.prepare("SELECT storage_name FROM attachments")?;
2690        let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
2691        rows.collect::<Result<HashSet<_>, _>>()?
2692    };
2693    if let Some(invalid) = owned
2694        .iter()
2695        .find(|storage_name| !is_managed_attachment_name(storage_name))
2696    {
2697        return Err(StoreError::Corrupt(format!(
2698            "attachment has invalid storage name {invalid:?}"
2699        )));
2700    }
2701    let pending = {
2702        let mut statement = tx.prepare("SELECT storage_name FROM attachment_gc")?;
2703        let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
2704        rows.collect::<Result<Vec<_>, _>>()?
2705    };
2706    if let Some(invalid) = pending
2707        .iter()
2708        .find(|storage_name| !is_managed_attachment_name(storage_name))
2709    {
2710        return Err(StoreError::Corrupt(format!(
2711            "attachment cleanup entry has invalid storage name {invalid:?}"
2712        )));
2713    }
2714
2715    let mut removed = false;
2716    if images_root.exists() {
2717        let entries = fs::read_dir(images_root)
2718            .map_err(|error| StoreError::io("read image directory", images_root, error))?;
2719        for entry in entries {
2720            let entry = entry
2721                .map_err(|error| StoreError::io("read image directory", images_root, error))?;
2722            let name = entry.file_name();
2723            let Some(name) = name.to_str() else {
2724                continue;
2725            };
2726            let stale_temp = is_managed_attachment_temp_name(name);
2727            let orphaned_managed = is_managed_attachment_name(name) && !owned.contains(name);
2728            if !stale_temp && !orphaned_managed {
2729                continue;
2730            }
2731            let file_type = entry.file_type().map_err(|error| {
2732                StoreError::io("inspect image directory entry", &entry.path(), error)
2733            })?;
2734            if file_type.is_file() || file_type.is_symlink() {
2735                removed |= remove_managed_attachment_file(&entry.path())?;
2736            }
2737        }
2738    }
2739    if removed {
2740        sync_images_directory(images_root)?;
2741    }
2742    tx.execute("DELETE FROM attachment_gc", [])?;
2743    tx.commit()?;
2744    Ok(())
2745}
2746
2747#[derive(Clone, Copy)]
2748enum DueMode {
2749    NewWrite,
2750    LegacyMigration,
2751    Stored,
2752}
2753
2754#[derive(Clone, Copy, PartialEq, Eq)]
2755enum AttachmentMode {
2756    Draft,
2757    Persisted,
2758}
2759
2760fn normalize_and_validate(
2761    data: &mut StoreData,
2762    now: NaiveDateTime,
2763    due_mode: DueMode,
2764    attachment_mode: AttachmentMode,
2765) -> Result<(), StoreError> {
2766    // This compatibility-only field moved to the application-level updater
2767    // store. Never let it re-enter persisted task settings.
2768    data.settings.last_update_check_at = None;
2769    if data.categories.len() > MAX_CATEGORY_COUNT {
2770        return Err(StoreError::Validation(format!(
2771            "category limit is {MAX_CATEGORY_COUNT}"
2772        )));
2773    }
2774    if data.tasks.len() > MAX_TASK_COUNT {
2775        return Err(StoreError::Validation(format!(
2776            "task limit is {MAX_TASK_COUNT}"
2777        )));
2778    }
2779    if data.labels.len() > MAX_LABEL_COUNT {
2780        return Err(StoreError::Validation(format!(
2781            "label limit is {MAX_LABEL_COUNT}"
2782        )));
2783    }
2784
2785    let attachment_ids = validate_attachments(&data.attachments)?;
2786
2787    let mut category_ids = HashSet::new();
2788    let mut category_names = HashSet::new();
2789    for category in &data.categories {
2790        validate_single_line(&category.id, "category id")?;
2791        validate_byte_limit(&category.id, ID_MAX_BYTES, "category id")?;
2792        if category.is_all() {
2793            return Err(StoreError::Validation(
2794                "real category id cannot be empty".into(),
2795            ));
2796        }
2797        if !category_ids.insert(category.id.as_str()) {
2798            return Err(StoreError::Validation(format!(
2799                "category id {:?} must be unique",
2800                category.id
2801            )));
2802        }
2803        validate_single_line(&category.name, "category name")?;
2804        validate_byte_limit(
2805            &category.name,
2806            text_byte_limit(MAX_CATEGORY_NAME_LEN),
2807            "category name",
2808        )?;
2809        let name = category.name.trim();
2810        if name.is_empty() {
2811            return Err(StoreError::Validation(
2812                "category name cannot be empty".into(),
2813            ));
2814        }
2815        if name.graphemes(true).count() > MAX_CATEGORY_NAME_LEN {
2816            return Err(StoreError::Validation(format!(
2817                "category name {:?} exceeds {MAX_CATEGORY_NAME_LEN} characters",
2818                category.name
2819            )));
2820        }
2821        if !category_names.insert(category_name_key(name)) {
2822            return Err(StoreError::Validation(format!(
2823                "category names must be unique (duplicate {:?})",
2824                category.name
2825            )));
2826        }
2827        validate_multiline(
2828            &category.description,
2829            MAX_CATEGORY_DESC_LINES,
2830            MAX_CATEGORY_DESC_LINE_LEN,
2831            "category description",
2832        )?;
2833    }
2834
2835    let mut label_ids = HashSet::new();
2836    let mut label_names = HashSet::new();
2837    let mut label_positions = HashMap::new();
2838    for (position, label) in data.labels.iter_mut().enumerate() {
2839        validate_single_line(&label.id, "label id")?;
2840        validate_byte_limit(&label.id, ID_MAX_BYTES, "label id")?;
2841        if label.id.is_empty() || !label_ids.insert(label.id.clone()) {
2842            return Err(StoreError::Validation(format!(
2843                "label id {:?} must be nonempty and unique",
2844                label.id
2845            )));
2846        }
2847        validate_single_line(&label.name, "label name")?;
2848        validate_byte_limit(
2849            &label.name,
2850            text_byte_limit(MAX_LABEL_NAME_LEN),
2851            "label name",
2852        )?;
2853        let trimmed = label.name.trim();
2854        if trimmed.is_empty() {
2855            return Err(StoreError::Validation("label name cannot be empty".into()));
2856        }
2857        if trimmed.starts_with('#') {
2858            return Err(StoreError::Validation(
2859                "label name must not start with '#'".into(),
2860            ));
2861        }
2862        if trimmed.graphemes(true).count() > MAX_LABEL_NAME_LEN {
2863            return Err(StoreError::Validation(format!(
2864                "label name {:?} exceeds {MAX_LABEL_NAME_LEN} characters",
2865                label.name
2866            )));
2867        }
2868        if matches!(due_mode, DueMode::Stored) && trimmed != label.name {
2869            return Err(StoreError::Validation(format!(
2870                "label {:?} has noncanonical surrounding whitespace",
2871                label.id
2872            )));
2873        }
2874        label.name = trimmed.to_string();
2875        if !label_names.insert(label_name_key(&label.name)) {
2876            return Err(StoreError::Validation("label names must be unique".into()));
2877        }
2878        label_positions.insert(label.id.clone(), position);
2879    }
2880
2881    let mut task_ids = HashSet::new();
2882    for task in &mut data.tasks {
2883        validate_single_line(&task.id, "task id")?;
2884        validate_byte_limit(&task.id, ID_MAX_BYTES, "task id")?;
2885        if task.id.is_empty() || !task_ids.insert(task.id.as_str()) {
2886            return Err(StoreError::Validation(format!(
2887                "task id {:?} must be nonempty and unique",
2888                task.id
2889            )));
2890        }
2891        validate_single_line(&task.title, "task title")?;
2892        validate_byte_limit(&task.title, text_byte_limit(MAX_TITLE_LEN), "task title")?;
2893        if task.title.trim().is_empty() {
2894            return Err(StoreError::Validation(format!(
2895                "task {:?} title cannot be empty",
2896                task.id
2897            )));
2898        }
2899        if task.title.graphemes(true).count() > MAX_TITLE_LEN {
2900            return Err(StoreError::Validation(format!(
2901                "task {:?} title exceeds {MAX_TITLE_LEN} characters",
2902                task.id
2903            )));
2904        }
2905        if task.importance > MAX_IMPORTANCE {
2906            return Err(StoreError::Validation(format!(
2907                "task {:?} importance must be 0-{MAX_IMPORTANCE}",
2908                task.id
2909            )));
2910        }
2911        if task.description.len() > MAX_DESCRIPTION_LINES {
2912            return Err(StoreError::Validation(format!(
2913                "task {:?} description exceeds {MAX_DESCRIPTION_LINES} blocks",
2914                task.id
2915            )));
2916        }
2917        for block in &task.description {
2918            validate_block(block, &task.id)?;
2919            if let Block::Image { attachment_id } = block {
2920                let known = attachment_ids.contains(attachment_id.as_str());
2921                if attachment_mode == AttachmentMode::Persisted && !known {
2922                    return Err(StoreError::Validation(format!(
2923                        "task {:?} refers to unknown attachment {attachment_id:?}",
2924                        task.id
2925                    )));
2926                }
2927                if attachment_mode == AttachmentMode::Draft
2928                    && is_attachment_id(attachment_id)
2929                    && !known
2930                {
2931                    return Err(StoreError::Validation(format!(
2932                        "task {:?} refers to unknown attachment {attachment_id:?}",
2933                        task.id
2934                    )));
2935                }
2936            }
2937        }
2938        if let Some(category_id) = task.category_id.as_deref() {
2939            validate_single_line(category_id, "task category id")?;
2940            validate_byte_limit(category_id, ID_MAX_BYTES, "task category id")?;
2941            if !category_ids.contains(category_id) {
2942                return Err(StoreError::Validation(format!(
2943                    "task {:?} refers to unknown category {category_id:?}",
2944                    task.id
2945                )));
2946            }
2947        }
2948        if task.label_ids.len() > MAX_LABELS_PER_TASK {
2949            return Err(StoreError::Validation(format!(
2950                "task {:?} label limit is {MAX_LABELS_PER_TASK}",
2951                task.id
2952            )));
2953        }
2954        let mut assigned = HashSet::new();
2955        for label_id in &task.label_ids {
2956            validate_single_line(label_id, "task label id")?;
2957            validate_byte_limit(label_id, ID_MAX_BYTES, "task label id")?;
2958            if !assigned.insert(label_id.clone()) {
2959                return Err(StoreError::Validation(format!(
2960                    "task {:?} assigns label {label_id:?} more than once",
2961                    task.id
2962                )));
2963            }
2964            if !label_positions.contains_key(label_id) {
2965                return Err(StoreError::Validation(format!(
2966                    "task {:?} refers to unknown label {label_id:?}",
2967                    task.id
2968                )));
2969            }
2970        }
2971        let mut canonical_label_ids = task.label_ids.clone();
2972        canonical_label_ids.sort_by_key(|label_id| label_positions[label_id]);
2973        if matches!(due_mode, DueMode::Stored) && canonical_label_ids != task.label_ids {
2974            return Err(StoreError::Validation(format!(
2975                "task {:?} labels are not in canonical store order",
2976                task.id
2977            )));
2978        }
2979        task.label_ids = canonical_label_ids;
2980        validate_single_line(&task.due, "task due")?;
2981        validate_byte_limit(&task.due, DUE_MAX_BYTES, "task due")?;
2982        let normalized_due = match due_mode {
2983            DueMode::NewWrite | DueMode::Stored => due::normalize_for_write_at(&task.due, now),
2984            DueMode::LegacyMigration => due::normalize_legacy_at(&task.due, now),
2985        }
2986        .map_err(|error| StoreError::Validation(format!("task {:?} has {error}", task.id)))?;
2987        if matches!(due_mode, DueMode::Stored) && normalized_due != task.due {
2988            return Err(StoreError::Validation(format!(
2989                "task {:?} has noncanonical due value {:?}",
2990                task.id, task.due
2991            )));
2992        }
2993        task.due = normalized_due;
2994        validate_single_line(&task.created, "task creation timestamp")?;
2995        validate_byte_limit(&task.created, CREATED_MAX_BYTES, "task creation timestamp")?;
2996        NaiveDateTime::parse_from_str(&task.created, "%Y-%m-%d %H:%M:%S").map_err(|_| {
2997            StoreError::Validation(format!(
2998                "task {:?} has invalid creation timestamp {:?}",
2999                task.id, task.created
3000            ))
3001        })?;
3002    }
3003    validate_settings(&data.settings)
3004}
3005
3006fn validate_block(block: &Block, task_id: &str) -> Result<(), StoreError> {
3007    let (kind, value) = match block {
3008        Block::Text { text } => ("text", text),
3009        Block::Todo { text, .. } => ("subtask", text),
3010        Block::Bullet { text } => ("bullet", text),
3011        Block::Number { text } => ("number", text),
3012        Block::Link { url } => ("link", url),
3013        Block::Image { attachment_id } => ("image attachment", attachment_id),
3014    };
3015    validate_single_line(value, kind)?;
3016    validate_byte_limit(value, text_byte_limit(MAX_NOTES_LINE_LEN), kind)?;
3017    if value.graphemes(true).count() > MAX_NOTES_LINE_LEN {
3018        return Err(StoreError::Validation(format!(
3019            "task {task_id:?} {kind} exceeds {MAX_NOTES_LINE_LEN} characters"
3020        )));
3021    }
3022    Ok(())
3023}
3024
3025fn validate_attachments(attachments: &[Attachment]) -> Result<HashSet<&str>, StoreError> {
3026    let mut ids = HashSet::new();
3027    let mut storage_names = HashSet::new();
3028    for attachment in attachments {
3029        if !is_attachment_id(&attachment.id) || attachment.sha256 != attachment.id {
3030            return Err(StoreError::Validation(format!(
3031                "attachment {:?} has an invalid content address",
3032                attachment.id
3033            )));
3034        }
3035        if !ids.insert(attachment.id.as_str()) {
3036            return Err(StoreError::Validation(format!(
3037                "attachment id {:?} must be unique",
3038                attachment.id
3039            )));
3040        }
3041        if attachment.byte_len == 0 || attachment.byte_len > MAX_ATTACHMENT_BYTES {
3042            return Err(StoreError::Validation(format!(
3043                "attachment {:?} has invalid byte length {}",
3044                attachment.id, attachment.byte_len
3045            )));
3046        }
3047        let format = crate::image::managed_attachment_format_for_media_type(&attachment.media_type)
3048            .ok_or_else(|| {
3049                StoreError::Validation(format!(
3050                    "attachment {:?} has unsupported media type {:?}",
3051                    attachment.id, attachment.media_type
3052                ))
3053            })?;
3054        let expected_storage_name = format!("{}.{}", attachment.id, format.extension);
3055        if attachment.storage_name != expected_storage_name {
3056            return Err(StoreError::Validation(format!(
3057                "attachment {:?} has invalid storage name {:?}",
3058                attachment.id, attachment.storage_name
3059            )));
3060        }
3061        if !storage_names.insert(attachment.storage_name.as_str()) {
3062            return Err(StoreError::Validation(format!(
3063                "attachment storage name {:?} must be unique",
3064                attachment.storage_name
3065            )));
3066        }
3067    }
3068    Ok(ids)
3069}
3070
3071fn validate_multiline(
3072    value: &str,
3073    max_lines: usize,
3074    max_line_len: usize,
3075    label: &str,
3076) -> Result<(), StoreError> {
3077    let max_line_bytes = text_byte_limit(max_line_len);
3078    let max_total_bytes = max_lines.saturating_mul(max_line_bytes.saturating_add(1));
3079    validate_byte_limit(value, max_total_bytes, label)?;
3080    if value
3081        .chars()
3082        .any(|character| character.is_control() && character != '\n')
3083    {
3084        return Err(StoreError::Validation(format!(
3085            "{label} contains a control character"
3086        )));
3087    }
3088    for (index, line) in value.split('\n').enumerate() {
3089        if index >= max_lines {
3090            return Err(StoreError::Validation(format!(
3091                "{label} exceeds {max_lines} lines"
3092            )));
3093        }
3094        if line.len() > max_line_bytes {
3095            return Err(StoreError::Validation(format!(
3096                "{label} line exceeds {max_line_bytes} bytes"
3097            )));
3098        }
3099        if line.graphemes(true).count() > max_line_len {
3100            return Err(StoreError::Validation(format!(
3101                "{label} line exceeds {max_line_len} characters"
3102            )));
3103        }
3104    }
3105    Ok(())
3106}
3107
3108fn validate_single_line(value: &str, label: &str) -> Result<(), StoreError> {
3109    if value.chars().any(char::is_control) {
3110        return Err(StoreError::Validation(format!(
3111            "{label} contains a control character"
3112        )));
3113    }
3114    Ok(())
3115}
3116
3117fn validate_byte_limit(value: &str, max_bytes: usize, label: &str) -> Result<(), StoreError> {
3118    if value.len() > max_bytes {
3119        return Err(StoreError::Validation(format!(
3120            "{label} exceeds {max_bytes} bytes"
3121        )));
3122    }
3123    Ok(())
3124}
3125
3126fn category_name_has_prefix(name: &str, folded_query: &str) -> bool {
3127    let normalized: String = name.trim().nfkc().collect();
3128    normalized
3129        .char_indices()
3130        .skip(1)
3131        .map(|(index, _)| index)
3132        .chain(std::iter::once(normalized.len()))
3133        .any(|end| category_name_key(&normalized[..end]) == folded_query)
3134}
3135
3136fn label_name_has_prefix(name: &str, folded_query: &str) -> bool {
3137    let normalized: String = name.trim().nfkc().collect();
3138    normalized
3139        .char_indices()
3140        .skip(1)
3141        .map(|(index, _)| index)
3142        .chain(std::iter::once(normalized.len()))
3143        .any(|end| label_name_key(&normalized[..end]) == folded_query)
3144}
3145
3146fn validate_settings(settings: &Settings) -> Result<(), StoreError> {
3147    validate_single_line(&settings.date_format, "date format")?;
3148    validate_byte_limit(
3149        &settings.date_format,
3150        SETTINGS_VALUE_MAX_BYTES,
3151        "date format",
3152    )?;
3153    validate_single_line(&settings.selected_color, "theme")?;
3154    validate_byte_limit(&settings.selected_color, SETTINGS_VALUE_MAX_BYTES, "theme")?;
3155    validate_single_line(&settings.sort, "sort")?;
3156    validate_byte_limit(&settings.sort, SETTINGS_VALUE_MAX_BYTES, "sort")?;
3157    validate_single_line(&settings.preview_position, "preview position")?;
3158    validate_byte_limit(
3159        &settings.preview_position,
3160        SETTINGS_VALUE_MAX_BYTES,
3161        "preview position",
3162    )?;
3163    if let Some(version) = settings.last_run_version.as_deref() {
3164        validate_single_line(version, "last-run version")?;
3165        validate_byte_limit(version, SETTINGS_VALUE_MAX_BYTES, "last-run version")?;
3166    }
3167    if !DATE_FORMATS.contains(&settings.date_format.as_str()) {
3168        return Err(StoreError::Validation(format!(
3169            "unknown date format {:?}",
3170            settings.date_format
3171        )));
3172    }
3173    if !THEMES.contains(&settings.selected_color.as_str()) {
3174        return Err(StoreError::Validation(format!(
3175            "unknown theme {:?}",
3176            settings.selected_color
3177        )));
3178    }
3179    if !SORTS.contains(&settings.sort.as_str()) {
3180        return Err(StoreError::Validation(format!(
3181            "unknown sort {:?}",
3182            settings.sort
3183        )));
3184    }
3185    if !PREVIEW_POSITIONS.contains(&settings.preview_position.as_str()) {
3186        return Err(StoreError::Validation(format!(
3187            "unknown preview position {:?}",
3188            settings.preview_position
3189        )));
3190    }
3191    Ok(())
3192}
3193
3194fn read_optional_json<T: DeserializeOwned>(path: &Path) -> Result<Option<T>, StoreError> {
3195    let file = match fs::File::open(path) {
3196        Ok(file) => file,
3197        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
3198        Err(error) => return Err(StoreError::io("read", path, error)),
3199    };
3200    let size = file
3201        .metadata()
3202        .map_err(|error| StoreError::io("inspect", path, error))?
3203        .len();
3204    if size > MAX_LEGACY_JSON_BYTES {
3205        return Err(StoreError::Validation(format!(
3206            "legacy file {} is larger than the {} MiB safety limit",
3207            path.display(),
3208            MAX_LEGACY_JSON_BYTES / 1024 / 1024
3209        )));
3210    }
3211    serde_json::from_reader(std::io::BufReader::new(file))
3212        .map(Some)
3213        .map_err(|source| StoreError::Json {
3214            path: path.to_path_buf(),
3215            source,
3216        })
3217}
3218
3219fn validate_legacy_schema(path: &Path, schema: Option<u32>) -> Result<(), StoreError> {
3220    if let Some(found) = schema
3221        && found != SCHEMA_VERSION
3222    {
3223        return Err(StoreError::UnsupportedLegacySchema {
3224            path: path.to_path_buf(),
3225            found,
3226            expected: SCHEMA_VERSION,
3227        });
3228    }
3229    Ok(())
3230}
3231
3232#[derive(Debug, Deserialize)]
3233struct TasksFile {
3234    schema: u32,
3235    tasks: Vec<Task>,
3236}
3237
3238#[derive(Debug, Deserialize)]
3239struct CategoriesFile {
3240    schema: u32,
3241    categories: Vec<Category>,
3242}
3243
3244pub(crate) fn ensure_private_directory(path: &Path) -> Result<(), StoreError> {
3245    let created = match fs::create_dir(path) {
3246        Ok(()) => true,
3247        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists && path.is_dir() => false,
3248        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
3249            if let Some(parent) = path
3250                .parent()
3251                .filter(|parent| !parent.as_os_str().is_empty())
3252            {
3253                fs::create_dir_all(parent).map_err(|parent_error| {
3254                    StoreError::io("create parent directory", parent, parent_error)
3255                })?;
3256            }
3257            match fs::create_dir(path) {
3258                Ok(()) => true,
3259                Err(retry)
3260                    if retry.kind() == std::io::ErrorKind::AlreadyExists && path.is_dir() =>
3261                {
3262                    false
3263                }
3264                Err(retry) => {
3265                    return Err(StoreError::io("create directory", path, retry));
3266                }
3267            }
3268        }
3269        Err(error) => return Err(StoreError::io("create directory", path, error)),
3270    };
3271    if created {
3272        #[cfg(unix)]
3273        {
3274            use std::os::unix::fs::PermissionsExt;
3275            fs::set_permissions(path, fs::Permissions::from_mode(0o700))
3276                .map_err(|error| StoreError::io("set permissions on", path, error))?;
3277        }
3278    }
3279    Ok(())
3280}
3281
3282pub(crate) fn prepare_private_database_file(path: &Path) -> Result<(), StoreError> {
3283    #[cfg(unix)]
3284    {
3285        use std::os::unix::fs::OpenOptionsExt;
3286        match fs::OpenOptions::new()
3287            .write(true)
3288            .create_new(true)
3289            .mode(0o600)
3290            .open(path)
3291        {
3292            Ok(file) => drop(file),
3293            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
3294            Err(error) => return Err(StoreError::io("create database", path, error)),
3295        }
3296    }
3297    #[cfg(not(unix))]
3298    let _ = path;
3299    Ok(())
3300}
3301
3302pub(crate) fn set_private_file(path: &Path) -> Result<(), StoreError> {
3303    #[cfg(unix)]
3304    {
3305        use std::os::unix::fs::PermissionsExt;
3306        fs::set_permissions(path, fs::Permissions::from_mode(0o600))
3307            .map_err(|error| StoreError::io("set permissions on", path, error))?;
3308    }
3309    Ok(())
3310}
3311
3312#[cfg(test)]
3313mod tests {
3314    use super::*;
3315
3316    #[test]
3317    fn default_directory_requires_a_home_when_no_path_is_configured() {
3318        let error = resolve_data_dir_from(None, None, None)
3319            .expect_err("missing home must not silently select the working directory");
3320        assert!(matches!(error, StoreError::Validation(_)));
3321
3322        assert_eq!(
3323            resolve_data_dir_from(Some(PathBuf::from("/tmp/mach")), None, None).unwrap(),
3324            PathBuf::from("/tmp/mach")
3325        );
3326        assert_eq!(
3327            resolve_data_dir_from(None, Some(PathBuf::from("/tmp/configured")), None).unwrap(),
3328            PathBuf::from("/tmp/configured")
3329        );
3330        assert!(resolve_data_dir_from(Some(PathBuf::from("~/.mach")), None, None).is_err());
3331    }
3332}