Skip to main content

mach/
store.rs

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