Skip to main content

archelon_core/
ops.rs

1//! High-level entry operations shared across CLI, MCP, and future frontends.
2//!
3//! Each public function accepts already-parsed, typed arguments so that the
4//! caller only needs to handle input-format concerns (CLI arg parsing, JSON
5//! deserialization, etc.) and output formatting.
6
7use std::{cmp::Ordering, path::{Path, PathBuf}, str::FromStr};
8
9use indexmap::IndexMap;
10
11use caretta_id::CarettaId;
12use chrono::{Datelike as _, NaiveDateTime};
13use rusqlite::Connection;
14
15use crate::{
16    cache,
17    entry::{Entry, EntryHeader, EventMeta, Frontmatter, TaskMeta},
18    entry_ref::EntryRef,
19    error::{Error, Result},
20    journal::{DuplicateTitlePolicy, Journal, slugify},
21    journal_state::JournalState,
22    parser::{read_entry, render_entry},
23    period::Period,
24};
25
26// ── UpdateOption ──────────────────────────────────────────────────────────────
27
28/// Represents the three possible states for an optional field in an update
29/// operation: set it to a new value, clear it (set to `None`), or leave it
30/// unchanged.
31///
32/// # JSON / MCP representation
33///
34/// When used as a struct field with `#[serde(default)]`:
35/// - field absent  → `Unchanged` (via `Default`)
36/// - field `null`  → `Clear`
37/// - field `<value>` → `Set(value)`
38///
39/// The JSON schema is identical to `Option<T>` (nullable value).
40#[derive(Debug, Default)]
41pub enum UpdateOption<T> {
42    Set(T),
43    Clear,
44    #[default]
45    Unchanged,
46}
47
48impl<'de, T: serde::Deserialize<'de>> serde::Deserialize<'de> for UpdateOption<T> {
49    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
50        Ok(match Option::<T>::deserialize(deserializer)? {
51            None    => UpdateOption::Clear,
52            Some(v) => UpdateOption::Set(v),
53        })
54    }
55}
56
57impl<T: schemars::JsonSchema> schemars::JsonSchema for UpdateOption<T> {
58    fn schema_name() -> std::borrow::Cow<'static, str> {
59        format!("Nullable_{}", T::schema_name()).into()
60    }
61
62    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
63        // Reuse Option<T>'s schema: the inner value or null.
64        Option::<T>::json_schema(generator)
65    }
66
67    fn inline_schema() -> bool {
68        true
69    }
70}
71
72// ── SortField / SortOrder ─────────────────────────────────────────────────────
73
74/// Which field to sort entries by in [`list_entries`].
75///
76/// The default variant is `Unsorted`, which preserves the order returned by the
77/// cache or filesystem (no sort applied).  Callers that want to bypass Rust-side
78/// sorting — for example to perform locale-aware title sorting in a frontend —
79/// should leave `sort_by` as `Unsorted` and sort the results themselves.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
81pub enum SortField {
82    /// No sort — preserve cache / filesystem order.
83    #[default]
84    Unsorted,
85    Id,
86    Title,
87    TaskStatus,
88    CreatedAt,
89    UpdatedAt,
90    TaskDue,
91    EventStart,
92}
93
94impl FromStr for SortField {
95    type Err = String;
96    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
97        match s {
98            "id"           => Ok(Self::Id),
99            "title"        => Ok(Self::Title),
100            "task_status"  => Ok(Self::TaskStatus),
101            "created_at"   => Ok(Self::CreatedAt),
102            "updated_at"   => Ok(Self::UpdatedAt),
103            "task_due"     => Ok(Self::TaskDue),
104            "event_start"  => Ok(Self::EventStart),
105            other => Err(format!(
106                "unknown sort field `{other}`; expected one of: \
107                 id, title, task_status, created_at, updated_at, task_due, event_start"
108            )),
109        }
110    }
111}
112
113/// Sort direction for [`list_entries`].
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
115pub enum SortOrder {
116    #[default]
117    Asc,
118    Desc,
119}
120
121impl FromStr for SortOrder {
122    type Err = String;
123    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
124        match s {
125            "asc"  => Ok(Self::Asc),
126            "desc" => Ok(Self::Desc),
127            other  => Err(format!("unknown sort order `{other}`; expected `asc` or `desc`")),
128        }
129    }
130}
131
132// ── FieldSelector ─────────────────────────────────────────────────────────────
133
134/// Selects which fields and semantic task conditions to filter on.
135///
136/// When **all** fields are `false` (the default) and a `period` is set, the period is
137/// applied to every timestamp field simultaneously (OR — "all-fields" fallback).  Setting
138/// any field to `true` disables that fallback so only the selected conditions apply.
139///
140/// **Period-field selectors** (`event_span`, `created_at`, `updated_at`): restrict period
141/// matching to specific timestamp fields.  Without a `period`, a `true` flag means "the
142/// field must be present (set)".
143///
144/// **Semantic task selectors** — use the period end as a cutoff where applicable:
145/// - `task_overdue`: `closed_at` absent **and** `due ≤ period_end` (or `due < now`).
146/// - `task_in_progress`: `closed_at` absent **and** `started_at ≤ period_end` (or set).
147/// - `task_unstarted`: `started_at` and `closed_at` both absent; period is not applied.
148#[derive(Debug, Default, Clone)]
149pub struct FieldSelector {
150    pub task_overdue: bool,
151    pub task_in_progress: bool,
152    pub task_unstarted: bool,
153    pub event_span: bool,
154    pub created_at: bool,
155    pub updated_at: bool,
156}
157
158impl FieldSelector {
159    /// Returns `true` when no field of any kind is selected.
160    ///
161    /// When this returns `true` and a `period` is set, the period is applied to all
162    /// timestamp fields simultaneously (OR — "all-fields" fallback).
163    pub fn is_empty(&self) -> bool {
164        !self.task_overdue && !self.task_in_progress && !self.task_unstarted
165            && !self.event_span && !self.created_at && !self.updated_at
166    }
167
168    /// Return a selector with all fields enabled — equivalent to the `--active` CLI flag.
169    ///
170    /// Combines `task_overdue`, `task_in_progress`, `event_span`, `created_at`, and
171    /// `updated_at`.  `task_unstarted` is intentionally excluded because unstarted tasks
172    /// carry no timestamp relationship to the period.
173    pub fn active() -> Self {
174        Self {
175            task_overdue:     true,
176            task_in_progress: true,
177            task_unstarted:   false,
178            event_span:       true,
179            created_at:       true,
180            updated_at:       true,
181        }
182    }
183}
184
185// ── EntryFilter ───────────────────────────────────────────────────────────────
186
187/// Filter criteria for [`list_entries`].
188///
189/// `period` combined with `fields` forms the timestamp filter:
190/// - `period` set, `fields` empty → apply the period to all timestamp fields (OR).
191/// - `period` set, `fields` non-empty → apply the period to only the selected fields (OR).
192/// - `period` absent, `fields` non-empty → include entries where the selected fields exist.
193///
194/// `task_status` and `tags` are ANDed on top.
195#[derive(Debug, Default)]
196pub struct EntryFilter {
197    /// Period to match against timestamp fields.
198    pub period: Option<Period>,
199    /// Which fields the period applies to (empty = all fields).
200    pub fields: FieldSelector,
201    /// AND condition on task status (empty = no constraint).
202    pub task_status: Vec<String>,
203    /// AND condition: entry must contain ALL of these tags (empty = no constraint).
204    pub tags: Vec<String>,
205    /// Field to sort results by (default: `Unsorted` = preserve cache/filesystem order).
206    pub sort_by: SortField,
207    /// Sort direction (default: ascending).
208    pub sort_order: SortOrder,
209}
210
211impl EntryFilter {
212    pub fn has_timestamp_filter(&self) -> bool {
213        self.period.is_some() || !self.fields.is_empty()
214    }
215
216    pub fn has_any_filter(&self) -> bool {
217        self.has_timestamp_filter() || !self.task_status.is_empty() || !self.tags.is_empty()
218    }
219
220    /// Evaluate whether `entry` should be included.
221    ///
222    /// Returns `(include, labels)` where `labels` lists which timestamp fields
223    /// matched (empty when no timestamp filter is active).
224    pub fn matches(&self, entry: &EntryHeader) -> (bool, Vec<MatchFlag>) {
225        let mut labels = Vec::new();
226
227        let timestamp_ok = if self.has_timestamp_filter() {
228            let event_start_val = entry.frontmatter.event.as_ref().map(|e| e.start);
229            let event_end_val   = entry.frontmatter.event.as_ref().map(|e| e.end);
230            let created_val     = Some(entry.frontmatter.created_at);
231            let updated_val     = Some(entry.frontmatter.updated_at);
232
233            if let Some(p) = &self.period {
234                // No selectors at all → apply period to all timestamp fields simultaneously (OR).
235                // Any explicit selector (including semantic task selectors) disables this fallback.
236                let all = self.fields.is_empty();
237                if (all || self.fields.event_span) && p.overlaps_event(event_start_val, event_end_val) { labels.push(MatchFlag::EventSpan); }
238                if (all || self.fields.created_at) && p.matches(created_val)                           { labels.push(MatchFlag::CreatedAt); }
239                if (all || self.fields.updated_at) && p.matches(updated_val)                           { labels.push(MatchFlag::UpdatedAt); }
240
241                // task_overdue: incomplete task with due ≤ period end
242                if self.fields.task_overdue {
243                    if let Period::Range(_, end) = p {
244                        let is_overdue = entry.frontmatter.task.as_ref().is_some_and(|t| {
245                            t.closed_at.is_none() && t.due.is_some_and(|due| due <= *end)
246                        });
247                        if is_overdue { labels.push(MatchFlag::TaskOverdue); }
248                    }
249                }
250
251                // task_in_progress: incomplete task with started_at ≤ period end
252                if self.fields.task_in_progress {
253                    if let Period::Range(_, end) = p {
254                        let is_in_progress = entry.frontmatter.task.as_ref().is_some_and(|t| {
255                            t.closed_at.is_none() && t.started_at.is_some_and(|sa| sa <= *end)
256                        });
257                        if is_in_progress { labels.push(MatchFlag::TaskInProgress); }
258                    }
259                }
260
261                // task_unstarted: task that exists but has not been started (period not applied)
262                if self.fields.task_unstarted {
263                    let is_unstarted = entry.frontmatter.task.as_ref().is_some_and(|t| {
264                        t.started_at.is_none() && t.closed_at.is_none()
265                    });
266                    if is_unstarted { labels.push(MatchFlag::TaskUnstarted); }
267                }
268            } else {
269                // No period: period-field flags → check that the field exists (is set)
270                if self.fields.event_span && (event_start_val.is_some() || event_end_val.is_some()) { labels.push(MatchFlag::EventSpan); }
271                // created_at / updated_at are always set on every entry → no useful existence check
272
273                // task_overdue: incomplete task with due < now
274                if self.fields.task_overdue {
275                    let now = chrono::Local::now().naive_local();
276                    let is_overdue = entry.frontmatter.task.as_ref().is_some_and(|t| {
277                        t.closed_at.is_none() && t.due.is_some_and(|due| due < now)
278                    });
279                    if is_overdue { labels.push(MatchFlag::TaskOverdue); }
280                }
281
282                // task_in_progress: incomplete task with started_at set
283                if self.fields.task_in_progress {
284                    let is_in_progress = entry.frontmatter.task.as_ref().is_some_and(|t| {
285                        t.closed_at.is_none() && t.started_at.is_some()
286                    });
287                    if is_in_progress { labels.push(MatchFlag::TaskInProgress); }
288                }
289
290                // task_unstarted: task that exists but has not been started
291                if self.fields.task_unstarted {
292                    let is_unstarted = entry.frontmatter.task.as_ref().is_some_and(|t| {
293                        t.started_at.is_none() && t.closed_at.is_none()
294                    });
295                    if is_unstarted { labels.push(MatchFlag::TaskUnstarted); }
296                }
297            }
298
299            labels.dedup();
300            !labels.is_empty()
301        } else {
302            true
303        };
304
305        let status_ok = if !self.task_status.is_empty() {
306            entry.frontmatter.task.as_ref().is_some_and(|t| {
307                let s = t.status.as_str();
308                self.task_status.iter().any(|ts| ts == s)
309            })
310        } else {
311            true
312        };
313
314        let tags_ok = if !self.tags.is_empty() {
315            self.tags.iter().all(|tag| entry.frontmatter.tags.contains(tag))
316        } else {
317            true
318        };
319
320        (timestamp_ok && status_ok && tags_ok, labels)
321    }
322}
323
324// ── MatchFlag ────────────────────────────────────────────────────────────────
325
326/// Identifies which timestamp field caused an entry to match a filter.
327#[derive(Debug, Clone, Copy, PartialEq, Eq)]
328pub enum MatchFlag {
329    /// Task is incomplete (`closed_at` absent) and `due ≤ period_end` (or `due < now`).
330    TaskOverdue,
331    /// Task is incomplete (`closed_at` absent) and `started_at ≤ period_end` (or `started_at` set).
332    TaskInProgress,
333    /// Task exists but `started_at` and `closed_at` are both absent.
334    TaskUnstarted,
335    /// The filter period overlaps the event's [start, end] span.
336    EventSpan,
337    CreatedAt,
338    UpdatedAt,
339    /// Entry did not match the filter itself, but one or more of its
340    /// descendants did (included to preserve tree context).
341    ParentOfMatch,
342}
343
344impl MatchFlag {
345    pub fn as_str(self) -> &'static str {
346        match self {
347            MatchFlag::TaskOverdue    => "task_overdue",
348            MatchFlag::TaskInProgress => "task_in_progress",
349            MatchFlag::TaskUnstarted  => "task_unstarted",
350            MatchFlag::EventSpan      => "event_span",
351            MatchFlag::CreatedAt      => "created",
352            MatchFlag::UpdatedAt      => "updated",
353            MatchFlag::ParentOfMatch  => "parent_of_match",
354        }
355    }
356}
357
358impl serde::Serialize for MatchFlag {
359    fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
360        s.serialize_str(self.as_str())
361    }
362}
363
364// ── EntryListItem ─────────────────────────────────────────────────────────────
365
366/// A flat list item pairing an [`EntryHeader`] with its [`MatchFlag`]s.
367///
368/// Serializes all entry fields at the top level, with `match_flags` omitted
369/// when there is no active filter (i.e. when `match_flags` is `None`).
370#[derive(serde::Serialize)]
371pub struct EntryListItem {
372    #[serde(flatten)]
373    pub entry: EntryHeader,
374    #[serde(skip_serializing_if = "Option::is_none")]
375    pub match_flags: Option<Vec<MatchFlag>>,
376}
377
378// ── tree ──────────────────────────────────────────────────────────────────────
379
380/// A node in an entry hierarchy returned by [`build_entry_tree`].
381#[derive(serde::Serialize)]
382pub struct EntryTreeNode {
383    #[serde(flatten)]
384    pub entry: EntryHeader,
385    #[serde(skip_serializing_if = "Vec::is_empty")]
386    pub match_flags: Vec<MatchFlag>,
387    pub children: Vec<EntryTreeNode>,
388}
389
390/// Organise a flat list of `(entry, labels)` pairs into a forest (list of
391/// root trees) based on each entry's `parent_id`.
392///
393/// An entry is treated as a root when its `parent_id` is `None` **or** when
394/// its parent is not present in the provided list.  Sibling order within each
395/// level mirrors the order of the input slice (i.e. the sort order chosen by
396/// the caller is preserved).
397pub fn build_entry_tree(entries: Vec<(EntryHeader, Vec<MatchFlag>)>) -> Vec<EntryTreeNode> {
398    use std::collections::HashMap;
399
400    // Build an index: CarettaId → position in the input slice.
401    let id_index: HashMap<caretta_id::CarettaId, usize> = entries
402        .iter()
403        .enumerate()
404        .map(|(i, (e, _))| (e.frontmatter.id, i))
405        .collect();
406
407    // Determine which entries are roots (parent absent or parent not in list).
408    let is_root: Vec<bool> = entries
409        .iter()
410        .map(|(e, _)| {
411            e.frontmatter
412                .parent_id
413                .map_or(true, |pid| !id_index.contains_key(&pid))
414        })
415        .collect();
416
417    // Build children lists: parent_index → [child_indices] in input order.
418    let mut children_of: Vec<Vec<usize>> = vec![Vec::new(); entries.len()];
419    for (i, (e, _)) in entries.iter().enumerate() {
420        if let Some(pid) = e.frontmatter.parent_id {
421            if let Some(&parent_i) = id_index.get(&pid) {
422                children_of[parent_i].push(i);
423            }
424        }
425    }
426
427    // Move entries out of the Vec into a parallel structure of Options so we
428    // can take ownership during recursive construction without cloning.
429    let mut slots: Vec<Option<(EntryHeader, Vec<MatchFlag>)>> =
430        entries.into_iter().map(Some).collect();
431
432    fn build_node(
433        idx: usize,
434        slots: &mut Vec<Option<(EntryHeader, Vec<MatchFlag>)>>,
435        children_of: &Vec<Vec<usize>>,
436    ) -> EntryTreeNode {
437        let (entry, labels) = slots[idx].take().unwrap();
438        let children = children_of[idx]
439            .iter()
440            .map(|&ci| build_node(ci, slots, children_of))
441            .collect();
442        EntryTreeNode { entry, match_flags: labels, children }
443    }
444
445    (0..slots.len())
446        .filter(|&i| is_root[i])
447        .map(|i| build_node(i, &mut slots, &children_of))
448        .collect()
449}
450
451/// Enrich a filtered entry list with ancestor entries that were excluded by
452/// the filter but are needed to preserve tree context.
453///
454/// For every entry in `filtered` whose `parent_id` is not already present,
455/// this function walks up the parent chain and adds the missing ancestors
456/// with a single [`MatchFlag::ParentOfMatch`] label.  The ancestors are
457/// appended after the filtered entries; `build_entry_tree` then places them
458/// at the correct positions in the hierarchy.
459///
460/// If `filtered` is empty or no ancestors are missing this is a no-op.
461pub fn fill_ancestor_entries(
462    mut filtered: Vec<(EntryHeader, Vec<MatchFlag>)>,
463    state: &JournalState,
464) -> Result<Vec<(EntryHeader, Vec<MatchFlag>)>> {
465    use std::collections::{HashMap, HashSet};
466
467    if filtered.is_empty() {
468        return Ok(filtered);
469    }
470
471    // IDs already present in the filtered set.
472    let present: HashSet<CarettaId> = filtered.iter().map(|(e, _)| e.frontmatter.id).collect();
473
474    // Load the full entry index for parent lookups (cache already synced by caller).
475    let all_map: HashMap<CarettaId, EntryHeader> = {
476        let all = cache::list_entries_from_cache(&state.conn).unwrap_or_else(|_| {
477            state.journal
478                .collect_entries()
479                .unwrap_or_default()
480                .iter()
481                .filter_map(|p| read_entry(p).ok().map(EntryHeader::from))
482                .collect()
483        });
484        all.into_iter().map(|e| (e.frontmatter.id, e)).collect()
485    };
486
487    // Walk up from every filtered entry and collect missing ancestors.
488    // Use an IndexMap to preserve insertion order (roughly child→parent) and
489    // deduplicate automatically.
490    let mut to_add: IndexMap<CarettaId, EntryHeader> = IndexMap::new();
491    for (entry, _) in &filtered {
492        let mut current_pid = entry.frontmatter.parent_id;
493        while let Some(pid) = current_pid {
494            if present.contains(&pid) || to_add.contains_key(&pid) {
495                break;
496            }
497            match all_map.get(&pid) {
498                Some(parent) => {
499                    let next = parent.frontmatter.parent_id;
500                    to_add.insert(pid, parent.clone());
501                    current_pid = next;
502                }
503                None => break,
504            }
505        }
506    }
507
508    for (_, ancestor) in to_add {
509        filtered.push((ancestor, vec![MatchFlag::ParentOfMatch]));
510    }
511
512    Ok(filtered)
513}
514
515// ── list ──────────────────────────────────────────────────────────────────────
516
517/// Collect and filter journal entries using the open cache in `state`.
518///
519/// Incrementally syncs the cache before querying.
520/// Falls back to a disk scan if the cache is unavailable.
521///
522/// Returns `(entry, match_labels)` pairs. When no filter is active every
523/// entry is returned with an empty label list.
524pub fn list_entries(
525    state: &JournalState,
526    filter: &EntryFilter,
527) -> Result<Vec<(EntryHeader, Vec<MatchFlag>)>> {
528    let _ = cache::sync_cache(&state.journal, &state.conn);
529    if let Ok(entries) = cache::list_entries_from_cache(&state.conn) {
530        return apply_filter_and_sort(entries, filter);
531    }
532    // Fallback: read from disk when the cache is unavailable.
533    let entries: Vec<EntryHeader> = state.journal
534        .collect_entries()?
535        .iter()
536        .filter_map(|p| match read_entry(p) {
537            Ok(e) => Some(EntryHeader::from(e)),
538            Err(e) => { eprintln!("warn: {} — {e}", p.display()); None }
539        })
540        .collect();
541    apply_filter_and_sort(entries, filter)
542}
543
544fn apply_filter_and_sort(entries: Vec<EntryHeader>, filter: &EntryFilter) -> Result<Vec<(EntryHeader, Vec<MatchFlag>)>> {
545    let has_filter = filter.has_any_filter();
546    let mut result = Vec::new();
547    for entry in entries {
548        let (include, labels) = filter.matches(&entry);
549        if has_filter && !include {
550            continue;
551        }
552        result.push((entry, labels));
553    }
554    if filter.sort_by != SortField::Unsorted {
555        result.sort_by(|(a, _), (b, _)| {
556            let ord = sort_cmp(a, b, filter.sort_by);
557            if filter.sort_order == SortOrder::Desc { ord.reverse() } else { ord }
558        });
559    }
560    Ok(result)
561}
562
563fn sort_cmp(a: &EntryHeader, b: &EntryHeader, field: SortField) -> Ordering {
564    match field {
565        SortField::Unsorted => Ordering::Equal,
566        SortField::Id => a.id().cmp(&b.id()),
567        SortField::Title => a.title().cmp(b.title()),
568        SortField::TaskStatus => {
569            let sa = a.frontmatter.task.as_ref().map(|t| t.status.as_str()).unwrap_or("");
570            let sb = b.frontmatter.task.as_ref().map(|t| t.status.as_str()).unwrap_or("");
571            sa.cmp(sb)
572        }
573        SortField::CreatedAt  => a.frontmatter.created_at.cmp(&b.frontmatter.created_at),
574        SortField::UpdatedAt  => a.frontmatter.updated_at.cmp(&b.frontmatter.updated_at),
575        SortField::TaskDue    => cmp_opt(
576            a.frontmatter.task.as_ref().and_then(|t| t.due),
577            b.frontmatter.task.as_ref().and_then(|t| t.due),
578        ),
579        SortField::EventStart => cmp_opt(
580            a.frontmatter.event.as_ref().map(|e| e.start),
581            b.frontmatter.event.as_ref().map(|e| e.start),
582        ),
583    }
584}
585
586/// Compare two `Option<T>` values; `None` sorts after `Some(_)`.
587fn cmp_opt<T: Ord>(a: Option<T>, b: Option<T>) -> Ordering {
588    match (a, b) {
589        (Some(x), Some(y)) => x.cmp(&y),
590        (Some(_), None)    => Ordering::Less,
591        (None,    Some(_)) => Ordering::Greater,
592        (None,    None)    => Ordering::Equal,
593    }
594}
595
596
597// ── EntryFields ───────────────────────────────────────────────────────────────
598
599/// Parsed frontmatter fields used for creating or updating an entry.
600///
601/// All fields are optional.  For [`create_entry`], `title` defaults to an
602/// empty string when `None`; `body` defaults to empty.  For [`update_entry`],
603/// `None` means "leave unchanged".
604#[derive(Debug, Default)]
605pub struct EntryFields {
606    pub title: Option<String>,
607    pub body: Option<String>,
608    /// Parent entry reference.  Resolved to a `CarettaId` via the cache at
609    /// write time.  `Unchanged` means "leave parent unchanged" in update; "no
610    /// parent" in create.  `Clear` sets `parent_id` to `None`.
611    pub parent: UpdateOption<EntryRef>,
612    pub slug: Option<String>,
613    /// `None` = leave tags unchanged; `Some([])` = clear all tags.
614    pub tags: Option<Vec<String>>,
615    pub task_due: Option<NaiveDateTime>,
616    pub task_status: Option<String>,
617    pub task_started_at: Option<NaiveDateTime>,
618    pub task_closed_at: Option<NaiveDateTime>,
619    pub event_start: Option<NaiveDateTime>,
620    pub event_end: Option<NaiveDateTime>,
621}
622
623// ── create ────────────────────────────────────────────────────────────────────
624
625/// Create a new entry in `journal` with the given `fields`.
626///
627/// `fields.title` defaults to `""` when `None`; `fields.body` defaults to `""`.
628///
629/// Fails with:
630/// - [`Error::DuplicateTitle`] if another entry already has the same title.
631/// - [`Error::DuplicateId`] if the generated ID already exists in the cache
632///   (extremely rare in practice).
633/// - [`Error::EntryAlreadyExists`] if the destination file already exists on disk.
634/// - [`Error::EntryNotFound`] / [`Error::EntryNotFoundByTitle`] if `fields.parent`
635///   cannot be resolved.
636pub fn create_entry(state: &JournalState, fields: EntryFields) -> Result<PathBuf> {
637    let journal = &state.journal;
638    let conn = &state.conn;
639    let id = CarettaId::now_unix();
640    let year = chrono::Local::now().year();
641
642    let title = fields.title.unwrap_or_default();
643    let body = fields.body.unwrap_or_default();
644
645    // ── duplicate title check ──────────────────────────────────────────────
646    if !title.is_empty() {
647        let dup_policy = journal.config().unwrap_or_default().journal.duplicate_title;
648        if dup_policy != DuplicateTitlePolicy::Allow {
649            let count: i64 = conn.query_row(
650                "SELECT COUNT(*) FROM entries WHERE title = ?1",
651                [&title],
652                |row| row.get(0),
653            )?;
654            if count > 0 {
655                match dup_policy {
656                    DuplicateTitlePolicy::Warn => {
657                        eprintln!("warn: duplicate title detected: `{title}`");
658                    }
659                    DuplicateTitlePolicy::Error => {
660                        return Err(Error::DuplicateTitle(title.clone()));
661                    }
662                    DuplicateTitlePolicy::Allow => unreachable!(),
663                }
664            }
665        }
666    }
667
668    // ── resolve parent ─────────────────────────────────────────────────────
669    let parent_id = match &fields.parent {
670        UpdateOption::Set(r) => resolve_parent_id(conn, Some(r))?,
671        UpdateOption::Clear | UpdateOption::Unchanged => None,
672    };
673
674    let tags = fields.tags.unwrap_or_default();
675
676    let task = if fields.task_due.is_some()
677        || fields.task_status.is_some()
678        || fields.task_started_at.is_some()
679        || fields.task_closed_at.is_some()
680    {
681        let status = fields.task_status.unwrap_or_else(|| "open".to_owned());
682        let inactive = matches!(status.as_str(), "done" | "cancelled" | "archived");
683        let in_progress = status == "in_progress";
684        let started_at = fields
685            .task_started_at
686            .or_else(|| in_progress.then(|| chrono::Local::now().naive_local()));
687        let closed_at = fields
688            .task_closed_at
689            .or_else(|| inactive.then(|| chrono::Local::now().naive_local()));
690        Some(TaskMeta { due: fields.task_due, status, started_at, closed_at, extra: IndexMap::new() })
691    } else {
692        None
693    };
694
695    let event = if fields.event_start.is_some() || fields.event_end.is_some() {
696        let start = fields.event_start.or(fields.event_end).unwrap();
697        let end = fields.event_end.or(fields.event_start).unwrap();
698        Some(EventMeta { start, end, extra: IndexMap::new() })
699    } else {
700        None
701    };
702
703    let now = chrono::Local::now().naive_local();
704    let frontmatter = Frontmatter {
705        id,
706        parent_id,
707        title,
708        slug: fields.slug.unwrap_or_default(),
709        tags,
710        created_at: now,
711        updated_at: now,
712        task,
713        event,
714        extra: IndexMap::new(),
715    };
716
717    let dest = journal.entries_root()?
718        .join(year.to_string())
719        .join(entry_filename_from_frontmatter(id, &frontmatter));
720    if dest.exists() {
721        return Err(Error::EntryAlreadyExists(dest.display().to_string()));
722    }
723
724    let entry = Entry { path: dest.clone(), frontmatter, body };
725
726    std::fs::create_dir_all(dest.parent().unwrap())?;
727    std::fs::write(&dest, render_entry(&entry))?;
728    Ok(dest)
729}
730
731// ── update ────────────────────────────────────────────────────────────────────
732
733/// Update the frontmatter of the entry at `path` with non-`None` fields.
734///
735/// `updated_at` is refreshed automatically by [`write_entry`].
736/// If the title or slug changed, the file is also renamed to match the new
737/// canonical filename.  Returns `Some(new_path)` when renamed, `None` otherwise.
738///
739/// Fails with [`Error::DuplicateTitle`] if another entry already uses the new
740/// title.  Fails with [`Error::EntryNotFound`] / [`Error::EntryNotFoundByTitle`]
741/// if `fields.parent` cannot be resolved.
742pub fn update_entry(path: &Path, conn: &Connection, fields: EntryFields) -> Result<Option<PathBuf>> {
743    let mut entry = read_entry(path)?;
744
745    if let Some(t) = fields.title {
746        // ── duplicate title check (exclude current entry) ──────────────────
747        if t != entry.frontmatter.title && !t.is_empty() {
748            let count: i64 = conn.query_row(
749                "SELECT COUNT(*) FROM entries WHERE title = ?1 AND id != ?2",
750                rusqlite::params![t, entry.frontmatter.id],
751                |row| row.get(0),
752            )?;
753            if count > 0 {
754                return Err(Error::DuplicateTitle(t));
755            }
756        }
757        entry.frontmatter.title = t;
758    }
759    if let Some(b) = fields.body {
760        entry.body = b;
761    }
762    match &fields.parent {
763        UpdateOption::Set(r) => {
764            entry.frontmatter.parent_id = Some(resolve_parent_id(conn, Some(r))?.unwrap());
765        }
766        UpdateOption::Clear => {
767            entry.frontmatter.parent_id = None;
768        }
769        UpdateOption::Unchanged => {}
770    }
771    if let Some(s) = fields.slug {
772        entry.frontmatter.slug = s;
773    }
774    if let Some(ts) = fields.tags {
775        entry.frontmatter.tags = ts;
776    }
777
778    if fields.task_due.is_some()
779        || fields.task_status.is_some()
780        || fields.task_started_at.is_some()
781        || fields.task_closed_at.is_some()
782    {
783        let task = entry.frontmatter.task.get_or_insert_with(|| TaskMeta {
784            status: "open".to_owned(),
785            due: None,
786            started_at: None,
787            closed_at: None,
788            extra: IndexMap::new(),
789        });
790        if let Some(d) = fields.task_due {
791            task.due = Some(d);
792        }
793        if let Some(s) = fields.task_status {
794            let in_progress = s == "in_progress";
795            let inactive = matches!(s.as_str(), "done" | "cancelled" | "archived");
796            task.status = s;
797            if in_progress && task.started_at.is_none() && fields.task_started_at.is_none() {
798                task.started_at = Some(chrono::Local::now().naive_local());
799            }
800            if inactive && task.closed_at.is_none() && fields.task_closed_at.is_none() {
801                task.closed_at = Some(chrono::Local::now().naive_local());
802            }
803        }
804        if let Some(sa) = fields.task_started_at {
805            task.started_at = Some(sa);
806        }
807        if let Some(ca) = fields.task_closed_at {
808            task.closed_at = Some(ca);
809        }
810    }
811
812    if fields.event_start.is_some() || fields.event_end.is_some() {
813        let event = entry.frontmatter.event.get_or_insert_with(|| {
814            let start = fields.event_start.or(fields.event_end).unwrap();
815            let end = fields.event_end.or(fields.event_start).unwrap();
816            EventMeta { start, end, extra: IndexMap::new() }
817        });
818        if let Some(s) = fields.event_start {
819            event.start = s;
820        }
821        if let Some(e) = fields.event_end {
822            event.end = e;
823        }
824    }
825
826    fix_entry_mut(&mut entry)
827}
828
829// ── helpers ───────────────────────────────────────────────────────────────────
830
831/// Resolve an optional [`EntryRef`] to the corresponding `CarettaId` by looking
832/// up the entry in the cache.  Returns `Ok(None)` when `parent` is `None`.
833pub fn resolve_parent_id(conn: &Connection, parent: Option<&EntryRef>) -> Result<Option<CarettaId>> {
834    match parent {
835        None => Ok(None),
836        Some(EntryRef::Id(id)) => Ok(Some(*id)),
837        Some(EntryRef::Path(path)) => Ok(Some(read_entry(path)?.frontmatter.id)),
838        Some(EntryRef::Title(title)) => {
839            Ok(Some(cache::find_entry_by_title(conn, title)?.frontmatter.id))
840        }
841    }
842}
843
844// ── prepare new (for editor workflow) ─────────────────────────────────────────
845
846/// Create a new entry file with a frontmatter template in the journal's year directory.
847///
848/// The file is named `<id>.md` with required frontmatter pre-filled and optional
849/// fields commented out.  The caller should open an editor on the returned path
850/// and then call [`fix_entry`] to rename the file once the user has set a title.
851///
852/// When `parent_id` is `Some`, the `parent_id` field is included in the frontmatter.
853pub fn prepare_new_entry(journal: &Journal, parent_id: Option<CarettaId>) -> Result<PathBuf> {
854    let id = CarettaId::now_unix();
855    let year = chrono::Local::now().year();
856    let now = chrono::Local::now().naive_local();
857    let now_fmt = now.format("%Y-%m-%dT%H:%M");
858
859    let dir = journal.entries_root()?.join(year.to_string());
860    std::fs::create_dir_all(&dir)?;
861
862    let path = dir.join(format!("{id}.md"));
863
864    let parent_line = match parent_id {
865        Some(pid) => format!("parent_id: '{pid}'\n"),
866        None => String::new(),
867    };
868
869    let template = format!(
870        "---\n\
871         id: '{id}'\n\
872         {parent_line}\
873         title: ''\n\
874         created_at: {now_fmt}\n\
875         updated_at: {now_fmt}\n\
876         # slug: ''\n\
877         # tags: [tag1, tag2]\n\
878         # task:\n\
879         #   status: open\n\
880         #   due: YYYY-MM-DD\n\
881         # event:\n\
882         #   start: YYYY-MM-DD\n\
883         #   end: YYYY-MM-DD\n\
884         ---\n\n"
885    );
886
887    std::fs::write(&path, template)?;
888    Ok(path)
889}
890
891// ── EntryRef resolution ───────────────────────────────────────────────────────
892
893/// Resolve an [`EntryRef`] to a concrete path, opening the journal when needed.
894///
895/// - `Path` variant: returned as-is.
896/// - `Id` variant: looks up by exact CarettaId via the cache.
897/// - `Title` variant: looks up by exact title (case-sensitive) via the cache.
898/// Resolve an [`EntryRef`] to an absolute path using an already-open cache connection.
899pub fn resolve_entry(entry_ref: &EntryRef, conn: &Connection) -> Result<PathBuf> {
900    match entry_ref {
901        EntryRef::Path(p) => Ok(p.clone()),
902        EntryRef::Id(id) => cache::find_entry_by_id(conn, *id).map(|e| e.path),
903        EntryRef::Title(title) => cache::find_entry_by_title(conn, title).map(|e| e.path),
904    }
905}
906
907// ── CheckIssue ────────────────────────────────────────────────────────────────
908
909/// A problem reported by [`check_entry`].
910#[derive(Debug, Clone)]
911pub enum CheckIssue {
912    /// The filename does not match the ID + title/slug derived from the frontmatter.
913    FilenameMismatch {
914        /// The filename the entry *should* have.
915        expected_filename: String,
916    },
917}
918
919impl CheckIssue {
920    pub fn as_str(&self) -> String {
921        match self {
922            CheckIssue::FilenameMismatch { expected_filename } =>
923                format!("filename mismatch — should be `{expected_filename}`"),
924        }
925    }
926}
927
928// ── check ─────────────────────────────────────────────────────────────────────
929
930/// Validate an entry's frontmatter and filename.
931///
932/// Returns a (possibly empty) list of [`CheckIssue`]s.
933/// An empty list means the entry passes all checks.
934pub fn check_entry(path: &Path) -> Result<Vec<CheckIssue>> {
935    let entry = read_entry(path)?;
936    let expected = entry_filename_from_frontmatter(entry.frontmatter.id, &entry.frontmatter);
937    let actual = path.file_name().and_then(|s| s.to_str()).unwrap_or_default();
938
939    let mut issues = Vec::new();
940    if actual != expected {
941        issues.push(CheckIssue::FilenameMismatch { expected_filename: expected });
942    }
943    Ok(issues)
944}
945
946// ── fix ───────────────────────────────────────────────────────────────────────
947
948/// If the entry has a task with `in_progress` status and no `started_at`, set it to now.
949fn sync_started_at(entry: &mut Entry) {
950    if let Some(task) = &mut entry.frontmatter.task {
951        if task.status == "in_progress" && task.started_at.is_none() {
952            task.started_at = Some(chrono::Local::now().naive_local());
953        }
954    }
955}
956
957/// If the entry has a task with a closed status and no `closed_at`, set it to now.
958fn sync_closed_at(entry: &mut Entry) {
959    if let Some(task) = &mut entry.frontmatter.task {
960        let is_closed = matches!(task.status.as_str(), "done" | "cancelled" | "archived");
961        if is_closed && task.closed_at.is_none() {
962            task.closed_at = Some(chrono::Local::now().naive_local());
963        }
964    }
965}
966
967/// Core fix logic on an already-loaded entry: sync `closed_at`, update `updated_at`,
968/// write the file, and rename it if the filename no longer matches the frontmatter.
969///
970/// Returns `Some(new_path)` if the file was renamed, `None` otherwise.
971fn fix_entry_mut(entry: &mut Entry) -> Result<Option<PathBuf>> {
972    sync_started_at(entry);
973    sync_closed_at(entry);
974    entry.frontmatter.updated_at = chrono::Local::now().naive_local();
975    std::fs::write(&entry.path, render_entry(entry))?;
976
977    let expected = entry_filename_from_frontmatter(entry.frontmatter.id, &entry.frontmatter);
978    let path = entry.path.clone();
979    let current_dir = path.parent().unwrap_or_else(|| Path::new("."));
980
981    // Entries live in <journal_root>/<year>/<file>.
982    // If the current directory name is a 4-digit year, compute the expected
983    // year directory from created_at so the file is moved when necessary.
984    let expected_dir = if let Some(journal_root) = current_dir.parent() {
985        let current_year_name = current_dir
986            .file_name()
987            .and_then(|n| n.to_str())
988            .unwrap_or("");
989        if current_year_name.len() == 4 && current_year_name.chars().all(|c| c.is_ascii_digit()) {
990            let year = entry.frontmatter.created_at.year();
991            journal_root.join(year.to_string())
992        } else {
993            current_dir.to_path_buf()
994        }
995    } else {
996        current_dir.to_path_buf()
997    };
998
999    let new_path = expected_dir.join(&expected);
1000
1001    if path == new_path {
1002        return Ok(None);
1003    }
1004
1005    std::fs::create_dir_all(&expected_dir)?;
1006    std::fs::rename(&path, &new_path)?;
1007    Ok(Some(new_path))
1008}
1009
1010/// Normalize an entry: sync `closed_at`, update `updated_at`, and rename the file
1011/// to match its frontmatter ID and title/slug.
1012///
1013/// Returns `Some(new_path)` if the file was renamed, `None` if it was already correct.
1014/// Returns `Err` if the file is not a managed entry.
1015pub fn fix_entry(path: &Path) -> Result<Option<PathBuf>> {
1016    let mut entry = read_entry(path)?;
1017    fix_entry_mut(&mut entry)
1018}
1019
1020// ── remove ────────────────────────────────────────────────────────────────────
1021
1022/// Delete an entry file from disk.
1023pub fn remove_entry(path: &Path) -> Result<()> {
1024    std::fs::remove_file(path).map_err(Error::Io)
1025}
1026
1027// ── internal helpers ──────────────────────────────────────────────────────────
1028
1029/// Build the canonical filename for an entry using the frontmatter slug (if set)
1030/// or `slugify(title)` as a fallback.
1031pub(crate) fn entry_filename_from_frontmatter(id: CarettaId, fm: &Frontmatter) -> String {
1032    let slug = if !fm.slug.is_empty() {
1033        fm.slug.clone()
1034    } else if fm.title.is_empty() {
1035        String::new()
1036    } else {
1037        slugify(&fm.title)
1038    };
1039    if slug.is_empty() {
1040        format!("{id}.md")
1041    } else {
1042        format!("{id}_{slug}.md")
1043    }
1044}