Skip to main content

agd_core/
model.rs

1//! The records agd hands to callers: [`Item`], [`AgendaEntry`], [`Mutation`]
2//! and their parts, every schema name a record carries, and errors:
3//! [`AgdError`], its [`ErrorResponse`], and [`ErrorCode`], the one list of
4//! agd.error/v1 codes.
5//!
6//! These are plain data with no behavior beyond construction and display.
7//! A schema name or an error code is spelled only here, never as a literal
8//! where a record or error is made.
9//!
10//! # Error codes
11//!
12//! Every code names what went wrong with one rule, so a consumer can tell
13//! the kind of failure from the code alone:
14//!
15//! - `invalid_<thing>`: the input is wrong (`invalid_date`, `invalid_span`,
16//!   `invalid_config`); fix it and try again.
17//! - `unknown_<thing>`: a name that is not configured (`unknown_view`,
18//!   `unknown_template`); the message lists the names there are.
19//! - `<thing>_not_found`: a target that is missing (`file_not_found`,
20//!   `journal_not_found`).
21//! - `<thing>_conflict`: something changed or clashes since it was read
22//!   (`ref_conflict`, `id_conflict`, `undo_conflict`); nothing is written.
23//! - `<thing>_failed`: an operation that failed (`io_failed`,
24//!   `lock_failed`, `parse_failed`).
25//!
26//! One failure has one code on every surface: the CLI's JSON, MCP and
27//! `agd setup check`.
28
29use std::fmt;
30
31use serde::{Deserialize, Serialize};
32
33pub const ITEM_SCHEMA: &str = "agd.item/v1";
34pub const AGENDA_ENTRY_SCHEMA: &str = "agd.agenda-entry/v1";
35pub const MUTATION_SCHEMA: &str = "agd.mutation/v1";
36pub const ERROR_SCHEMA: &str = "agd.error/v1";
37/// One block of `agd agenda --view KEY --json`.
38pub const VIEW_SECTION_SCHEMA: &str = "agd.view-section/v1";
39/// The payload of `agd agenda --views --json`.
40pub const VIEWS_SCHEMA: &str = "agd.views/v1";
41/// The payload of `agd agenda --status --json`: the status band's counts
42/// (`AgendaSummary`) and the overdue rows (`overdue_entries`).
43pub const STATUS_SCHEMA: &str = "agd.status/v1";
44/// An `agd setup check` report (`CheckReport`).
45pub const CHECK_SCHEMA: &str = "agd.check/v1";
46/// One line of the write journal (`JournalRecord`).
47pub const JOURNAL_SCHEMA: &str = "agd.journal/v1";
48/// What an undo wrote (`UndoResult`).
49pub const UNDO_SCHEMA: &str = "agd.undo/v1";
50/// What a refile wrote (`RefileResult`).
51pub const REFILE_SCHEMA: &str = "agd.refile/v1";
52/// The MCP `describe` tool's answer.
53pub const DESCRIBE_SCHEMA: &str = "agd.describe/v1";
54
55#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
56pub struct Item {
57    pub schema: String,
58    #[serde(rename = "ref")]
59    pub item_ref: String,
60    pub id: Option<String>,
61    pub file: String,
62    pub line: usize,
63    pub olpath: Vec<String>,
64    pub todo: Option<String>,
65    pub done: bool,
66    pub priority: Option<String>,
67    pub title: String,
68    pub tags: Vec<String>,
69    pub inherited_tags: Vec<String>,
70    pub category: String,
71    pub planning: Planning,
72    /// The entry's checklist progress, which lists show as `1/3`; absent
73    /// without a checklist, or when the heading has its own `[/]` or `[%]`
74    /// cookie.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub checklist: Option<Progress>,
77}
78
79/// A checklist's progress: `done` of its `total` boxes are checked.
80#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
81pub struct Progress {
82    pub done: usize,
83    pub total: usize,
84}
85
86#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
87pub struct Planning {
88    pub scheduled: Option<PlanningTimestamp>,
89    pub deadline: Option<PlanningTimestamp>,
90    pub closed: Option<PlanningTimestamp>,
91}
92
93/// An Org timestamp: `date` is `YYYY-MM-DD`, and `time` and `end_time` are
94/// always `HH:MM` (`<… 9:30>` reads `09:30`); `raw` is the text as the file
95/// writes it, which a write that leaves the timestamp alone keeps.
96#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
97pub struct PlanningTimestamp {
98    pub date: String,
99    pub time: Option<String>,
100    pub end_time: Option<String>,
101    pub repeater: Option<String>,
102    pub warning: Option<String>,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub raw: Option<String>,
105}
106
107#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
108pub struct AgendaEntry {
109    pub schema: String,
110    pub item: Item,
111    pub occurrence: Occurrence,
112}
113
114#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
115pub struct Occurrence {
116    #[serde(rename = "type")]
117    pub kind: OccurrenceType,
118    pub date: String,
119    /// `HH:MM`, as every time in the model.
120    pub time: Option<String>,
121    /// The end of a time range such as `10:00-11:30`; null without one.
122    #[serde(default)]
123    pub end_time: Option<String>,
124    /// Org's leader for the row, as the agenda prints it (`Sched.12x:`,
125    /// `3 d. ago:`, `In   5 d.:`).
126    pub extra: String,
127    /// Days late on an overdue row (`Sched.12x:` is 12, `3 d. ago:` is 3),
128    /// minus the days left on an upcoming deadline (`In   5 d.:` is -5),
129    /// and 0 on every other row.
130    #[serde(default)]
131    pub days: i64,
132    /// Whether the row is overdue: a past-scheduled reminder or a missed
133    /// deadline, both shown on today.
134    #[serde(default)]
135    pub late: bool,
136}
137
138#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
139#[serde(rename_all = "kebab-case")]
140pub enum OccurrenceType {
141    Scheduled,
142    PastScheduled,
143    Deadline,
144    UpcomingDeadline,
145    Timestamp,
146    Block,
147    Closed,
148    Todo,
149}
150
151#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
152pub struct Mutation {
153    pub schema: String,
154    pub ok: bool,
155    pub item: Item,
156    pub diff: String,
157    pub journal_id: String,
158    /// Set when marking a repeating task done moved its dates on instead
159    /// of closing it.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub repeated: Option<Repeat>,
162}
163
164/// A repeating task marked done (org-auto-repeat-maybe): the done state
165/// that was recorded, and the next occurrence the item now has.
166#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
167pub struct Repeat {
168    /// The done keyword requested, such as `DONE`.
169    pub done_state: String,
170    /// `YYYY-MM-DD` of the repeating SCHEDULED date, else of the repeating
171    /// DEADLINE.
172    pub next_date: String,
173}
174
175#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
176pub struct ErrorResponse {
177    pub schema: String,
178    pub code: String,
179    pub message: String,
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub current: Option<Item>,
182}
183
184/// Every agd.error/v1 `code`, in one place, for agd-core and the `agd`
185/// binary alike: each variant's `as_str` is the snake_case code consumers
186/// match on, and an error is raised as `AgdError::new(ErrorCode::Variant,
187/// message)`. `agd setup check` reports its problems with the same codes.
188///
189/// Every name follows one rule (see the module documentation):
190/// `invalid_<thing>`, `unknown_<thing>`, `<thing>_not_found`,
191/// `<thing>_conflict` or `<thing>_failed`.
192#[derive(Clone, Copy, Debug, Eq, PartialEq)]
193pub enum ErrorCode {
194    /// A capture template's heading, outline path or ID target that is
195    /// missing, or not unique on its level.
196    CaptureTargetNotFound,
197    /// A command that failed outside agd-core's own errors, such as output
198    /// that cannot be written.
199    CommandFailed,
200    /// A file an editor holds unsaved changes for: an Emacs `.#` lock exists.
201    EditorConflict,
202    /// A result that cannot be encoded as JSON.
203    EncodeFailed,
204    /// A file that is not under any root, or cannot be resolved.
205    FileNotFound,
206    /// An `:ID:` that more than one heading under the roots has, or that a
207    /// write would give a second heading.
208    IdConflict,
209    /// Editor configuration that cannot be found or read for an import.
210    ImportFailed,
211    /// An agenda file entry without the `.org` extension.
212    InvalidAgendaFile,
213    /// An agenda file pattern that is not a valid glob, or an absolute entry
214    /// that is not an exact file under a root.
215    InvalidAgendaPattern,
216    /// A command line, or an MCP tool call's arguments, that do not parse:
217    /// an unknown flag or argument, a missing value, a value of the wrong
218    /// type.
219    InvalidArguments,
220    /// A batch of edits that lists one item twice.
221    InvalidBatch,
222    /// A capture body line that would start a heading.
223    InvalidBody,
224    /// A capture line whose title is empty.
225    InvalidCapture,
226    /// A config file that is missing, unreadable or holds an invalid value,
227    /// or a config or data directory that cannot be determined.
228    InvalidConfig,
229    /// A date or time that does not parse; the message lists the forms it
230    /// takes.
231    InvalidDate,
232    /// An edit that would change nothing: it names no change, or the item's
233    /// bytes would stay as they are.
234    InvalidEdit,
235    /// A change to, or removal of, a heading's existing `:ID:`.
236    InvalidIdChange,
237    /// Imported editor configuration agd cannot turn into a config that
238    /// loads.
239    InvalidImport,
240    /// A journal line that is not a record, or an inverse patch that does
241    /// not restore the bytes the entry recorded.
242    InvalidJournal,
243    /// A review or undo of a journal entry in the wrong state: a review of
244    /// one not awaiting review, an undo of one that is not a committed
245    /// change.
246    InvalidJournalState,
247    /// A tags/TODO match expression that does not parse.
248    InvalidMatch,
249    /// A log note of several lines.
250    InvalidNote,
251    /// A patch that overlaps another, runs past the source or splits a UTF-8
252    /// character.
253    InvalidPatch,
254    /// A path agd will not touch: with a `..` segment, outside the roots, a
255    /// symlink to write through, without a parent, or not a regular file.
256    InvalidPath,
257    /// An MCP `plan` call with neither a scheduled nor a deadline date.
258    InvalidPlan,
259    /// A priority outside the file's range.
260    InvalidPriority,
261    /// A capture template prompt with no answer, or an answer of several
262    /// lines.
263    InvalidPrompt,
264    /// A property key outside `[A-Za-z0-9_-]`, or a value of several lines.
265    InvalidProperty,
266    /// An org-ql query that does not parse; the message lists the supported
267    /// predicates.
268    InvalidQuery,
269    /// An agenda range that ends before it starts.
270    InvalidRange,
271    /// An item ref that is neither an ID nor `file:line#sha256:digest`.
272    InvalidRef,
273    /// A refile into the item itself or its subtree, or one that would leave
274    /// a heading without stars.
275    InvalidRefile,
276    /// A repeater that does not parse, repeats every 0 units, or repeats past
277    /// the calendar.
278    InvalidRepeater,
279    /// No roots, or a root that cannot be resolved, is not a directory or is
280    /// listed twice.
281    InvalidRoot,
282    /// An agenda span that is not day, week, month or a number of days.
283    InvalidSpan,
284    /// A tag with characters outside Org's tag characters.
285    InvalidTag,
286    /// A capture template agd cannot use: one with a `%(sexp)` form, which
287    /// agd never runs, or one with neither a file nor an ID target.
288    InvalidTemplate,
289    /// A title that is not one line, that is cleared, or that the parser
290    /// would read as a TODO keyword, priority cookie or tags.
291    InvalidTitle,
292    /// A TODO state the item's file does not define.
293    InvalidTodo,
294    /// An org file that is not UTF-8.
295    InvalidUtf8,
296    /// A view group's effort bound that is not a duration.
297    InvalidView,
298    /// A file system operation that failed; the message names the path.
299    IoFailed,
300    /// A write met an interrupted earlier write whose file changed since.
301    JournalConflict,
302    /// A journal record that cannot be encoded.
303    JournalFailed,
304    /// A journal entry or transaction id that is not in the journal, or no
305    /// entry that `agd undo` may undo.
306    JournalNotFound,
307    /// A write lock that cannot be opened or taken.
308    LockFailed,
309    /// A file agd cannot read faithfully: the Org parser would not
310    /// reproduce it byte for byte, its text does not read as agd expects
311    /// (an unterminated property drawer), or a written heading cannot be
312    /// found again.
313    ParseFailed,
314    /// A relative path more than one root could hold.
315    PathConflict,
316    /// A ref whose heading changed, moved out of the agenda files or no
317    /// longer exists since it was read; the error's `current` is the item
318    /// as it reads now, when there is one.
319    RefConflict,
320    /// An undo whose change was edited since; nothing is written.
321    UndoConflict,
322    /// An agent profile name that is not configured.
323    UnknownProfile,
324    /// A capture template key that is not configured.
325    UnknownTemplate,
326    /// A view key that names no view, or `--view` without a key when no
327    /// start_view is set.
328    UnknownView,
329    /// A directory under a root that cannot be listed.
330    WalkFailed,
331}
332
333impl ErrorCode {
334    pub fn as_str(self) -> &'static str {
335        match self {
336            Self::CaptureTargetNotFound => "capture_target_not_found",
337            Self::CommandFailed => "command_failed",
338            Self::EditorConflict => "editor_conflict",
339            Self::EncodeFailed => "encode_failed",
340            Self::FileNotFound => "file_not_found",
341            Self::IdConflict => "id_conflict",
342            Self::ImportFailed => "import_failed",
343            Self::InvalidAgendaFile => "invalid_agenda_file",
344            Self::InvalidAgendaPattern => "invalid_agenda_pattern",
345            Self::InvalidArguments => "invalid_arguments",
346            Self::InvalidBatch => "invalid_batch",
347            Self::InvalidBody => "invalid_body",
348            Self::InvalidCapture => "invalid_capture",
349            Self::InvalidConfig => "invalid_config",
350            Self::InvalidDate => "invalid_date",
351            Self::InvalidEdit => "invalid_edit",
352            Self::InvalidIdChange => "invalid_id_change",
353            Self::InvalidImport => "invalid_import",
354            Self::InvalidJournal => "invalid_journal",
355            Self::InvalidJournalState => "invalid_journal_state",
356            Self::InvalidMatch => "invalid_match",
357            Self::InvalidNote => "invalid_note",
358            Self::InvalidPatch => "invalid_patch",
359            Self::InvalidPath => "invalid_path",
360            Self::InvalidPlan => "invalid_plan",
361            Self::InvalidPriority => "invalid_priority",
362            Self::InvalidPrompt => "invalid_prompt",
363            Self::InvalidProperty => "invalid_property",
364            Self::InvalidQuery => "invalid_query",
365            Self::InvalidRange => "invalid_range",
366            Self::InvalidRef => "invalid_ref",
367            Self::InvalidRefile => "invalid_refile",
368            Self::InvalidRepeater => "invalid_repeater",
369            Self::InvalidRoot => "invalid_root",
370            Self::InvalidSpan => "invalid_span",
371            Self::InvalidTag => "invalid_tag",
372            Self::InvalidTemplate => "invalid_template",
373            Self::InvalidTitle => "invalid_title",
374            Self::InvalidTodo => "invalid_todo",
375            Self::InvalidUtf8 => "invalid_utf8",
376            Self::InvalidView => "invalid_view",
377            Self::IoFailed => "io_failed",
378            Self::JournalConflict => "journal_conflict",
379            Self::JournalFailed => "journal_failed",
380            Self::JournalNotFound => "journal_not_found",
381            Self::LockFailed => "lock_failed",
382            Self::ParseFailed => "parse_failed",
383            Self::PathConflict => "path_conflict",
384            Self::RefConflict => "ref_conflict",
385            Self::UndoConflict => "undo_conflict",
386            Self::UnknownProfile => "unknown_profile",
387            Self::UnknownTemplate => "unknown_template",
388            Self::UnknownView => "unknown_view",
389            Self::WalkFailed => "walk_failed",
390        }
391    }
392}
393
394impl fmt::Display for ErrorCode {
395    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
396        formatter.write_str(self.as_str())
397    }
398}
399
400#[derive(Debug)]
401pub struct AgdError {
402    code: ErrorCode,
403    response: Box<ErrorResponse>,
404    source: Option<Box<dyn std::error::Error + Send + Sync>>,
405}
406
407impl AgdError {
408    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
409        Self {
410            code,
411            response: Box::new(ErrorResponse {
412                schema: ERROR_SCHEMA.to_string(),
413                code: code.as_str().to_string(),
414                message: message.into(),
415                current: None,
416            }),
417            source: None,
418        }
419    }
420
421    /// The same error with `context: ` before its message, such as which
422    /// of several targets it is about.
423    pub fn with_context(mut self, context: &str) -> Self {
424        self.response.message = format!("{context}: {}", self.response.message);
425        self
426    }
427
428    pub fn with_current(mut self, current: Item) -> Self {
429        self.response.current = Some(current);
430        self
431    }
432
433    pub fn with_source(
434        code: ErrorCode,
435        message: impl Into<String>,
436        source: impl std::error::Error + Send + Sync + 'static,
437    ) -> Self {
438        Self {
439            source: Some(Box::new(source)),
440            ..Self::new(code, message)
441        }
442    }
443
444    pub fn code(&self) -> ErrorCode {
445        self.code
446    }
447
448    pub fn response(&self) -> &ErrorResponse {
449        &self.response
450    }
451
452    pub fn into_response(self) -> ErrorResponse {
453        *self.response
454    }
455}
456
457impl fmt::Display for AgdError {
458    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
459        formatter.write_str(&self.response.message)
460    }
461}
462
463impl std::error::Error for AgdError {
464    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
465        self.source
466            .as_deref()
467            .map(|source| source as &(dyn std::error::Error + 'static))
468    }
469}
470
471impl From<std::io::Error> for AgdError {
472    fn from(error: std::io::Error) -> Self {
473        Self::with_source(ErrorCode::IoFailed, error.to_string(), error)
474    }
475}