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