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