agd-core 0.2.0

Org-mode parsing and task operations for agd
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
//! The records agd hands to callers: [`Item`], [`AgendaEntry`], [`Mutation`]
//! and their parts, every schema name a record carries, and errors:
//! [`AgdError`], its [`ErrorResponse`], and [`ErrorCode`], the one list of
//! agd.error/v1 codes.
//!
//! These are plain data with no behavior beyond construction and display.
//! A schema name or an error code is spelled only here, never as a literal
//! where a record or error is made.
//!
//! # Error codes
//!
//! Every code names what went wrong with one rule, so a consumer can tell
//! the kind of failure from the code alone:
//!
//! - `invalid_<thing>`: the input is wrong (`invalid_date`, `invalid_span`,
//!   `invalid_config`); fix it and try again.
//! - `unknown_<thing>`: a name that is not configured (`unknown_view`,
//!   `unknown_template`); the message lists the names there are.
//! - `<thing>_not_found`: a target that is missing (`file_not_found`,
//!   `journal_not_found`).
//! - `<thing>_conflict`: something changed or clashes since it was read
//!   (`ref_conflict`, `id_conflict`, `undo_conflict`); nothing is written.
//! - `<thing>_failed`: an operation that failed (`io_failed`,
//!   `lock_failed`, `parse_failed`).
//!
//! One failure has one code on every surface: the CLI's JSON, MCP and
//! `agd setup check`.

use std::fmt;

use serde::{Deserialize, Serialize};

pub const ITEM_SCHEMA: &str = "agd.item/v1";
pub const AGENDA_ENTRY_SCHEMA: &str = "agd.agenda-entry/v1";
pub const MUTATION_SCHEMA: &str = "agd.mutation/v1";
pub const ERROR_SCHEMA: &str = "agd.error/v1";
/// One block of `agd agenda --view KEY --json`.
pub const VIEW_SECTION_SCHEMA: &str = "agd.view-section/v1";
/// The payload of `agd agenda --views --json`.
pub const VIEWS_SCHEMA: &str = "agd.views/v1";
/// The payload of `agd agenda --status --json`: the status band's counts
/// (`AgendaSummary`) and the overdue rows (`overdue_entries`).
pub const STATUS_SCHEMA: &str = "agd.status/v1";
/// An `agd setup check` report (`CheckReport`).
pub const CHECK_SCHEMA: &str = "agd.check/v1";
/// One line of the write journal (`JournalRecord`).
pub const JOURNAL_SCHEMA: &str = "agd.journal/v1";
/// What an undo wrote (`UndoResult`).
pub const UNDO_SCHEMA: &str = "agd.undo/v1";
/// What a refile wrote (`RefileResult`).
pub const REFILE_SCHEMA: &str = "agd.refile/v1";
/// The MCP `describe` tool's answer.
pub const DESCRIBE_SCHEMA: &str = "agd.describe/v1";

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct Item {
    pub schema: String,
    #[serde(rename = "ref")]
    pub item_ref: String,
    pub id: Option<String>,
    pub file: String,
    pub line: usize,
    pub olpath: Vec<String>,
    pub todo: Option<String>,
    pub done: bool,
    pub priority: Option<String>,
    pub title: String,
    pub tags: Vec<String>,
    pub inherited_tags: Vec<String>,
    pub category: String,
    pub planning: Planning,
    /// The entry's checklist progress, which lists show as `1/3`; absent
    /// without a checklist, or when the heading has its own `[/]` or `[%]`
    /// cookie.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub checklist: Option<Progress>,
}

/// A checklist's progress: `done` of its `total` boxes are checked.
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct Progress {
    pub done: usize,
    pub total: usize,
}

#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct Planning {
    pub scheduled: Option<PlanningTimestamp>,
    pub deadline: Option<PlanningTimestamp>,
    pub closed: Option<PlanningTimestamp>,
}

/// An Org timestamp: `date` is `YYYY-MM-DD`, and `time` and `end_time` are
/// always `HH:MM` (`<… 9:30>` reads `09:30`); `raw` is the text as the file
/// writes it, which a write that leaves the timestamp alone keeps.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct PlanningTimestamp {
    pub date: String,
    pub time: Option<String>,
    pub end_time: Option<String>,
    pub repeater: Option<String>,
    pub warning: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub raw: Option<String>,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct AgendaEntry {
    pub schema: String,
    pub item: Item,
    pub occurrence: Occurrence,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct Occurrence {
    #[serde(rename = "type")]
    pub kind: OccurrenceType,
    pub date: String,
    /// `HH:MM`, as every time in the model.
    pub time: Option<String>,
    /// The end of a time range such as `10:00-11:30`; null without one.
    #[serde(default)]
    pub end_time: Option<String>,
    /// Org's leader for the row, as the agenda prints it (`Sched.12x:`,
    /// `3 d. ago:`, `In   5 d.:`).
    pub extra: String,
    /// Days late on an overdue row (`Sched.12x:` is 12, `3 d. ago:` is 3),
    /// minus the days left on an upcoming deadline (`In   5 d.:` is -5),
    /// and 0 on every other row.
    #[serde(default)]
    pub days: i64,
    /// Whether the row is overdue: a past-scheduled reminder or a missed
    /// deadline, both shown on today.
    #[serde(default)]
    pub late: bool,
}

#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum OccurrenceType {
    Scheduled,
    PastScheduled,
    Deadline,
    UpcomingDeadline,
    Timestamp,
    Block,
    Closed,
    Todo,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct Mutation {
    pub schema: String,
    pub ok: bool,
    pub item: Item,
    pub diff: String,
    pub journal_id: String,
    /// Set when marking a repeating task done moved its dates on instead
    /// of closing it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub repeated: Option<Repeat>,
}

/// A repeating task marked done (org-auto-repeat-maybe): the done state
/// that was recorded, and the next occurrence the item now has.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct Repeat {
    /// The done keyword requested, such as `DONE`.
    pub done_state: String,
    /// `YYYY-MM-DD` of the repeating SCHEDULED date, else of the repeating
    /// DEADLINE.
    pub next_date: String,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ErrorResponse {
    pub schema: String,
    pub code: String,
    pub message: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub current: Option<Item>,
}

/// Every agd.error/v1 `code`, in one place, for agd-core and the `agd`
/// binary alike: each variant's `as_str` is the snake_case code consumers
/// match on, and an error is raised as `AgdError::new(ErrorCode::Variant,
/// message)`. `agd setup check` reports its problems with the same codes.
///
/// Every name follows one rule (see the module documentation):
/// `invalid_<thing>`, `unknown_<thing>`, `<thing>_not_found`,
/// `<thing>_conflict` or `<thing>_failed`.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ErrorCode {
    /// A capture template's heading, outline path or ID target that is
    /// missing, or not unique on its level.
    CaptureTargetNotFound,
    /// A command that failed outside agd-core's own errors, such as output
    /// that cannot be written.
    CommandFailed,
    /// A file an editor holds unsaved changes for: an Emacs `.#` lock exists.
    EditorConflict,
    /// A result that cannot be encoded as JSON.
    EncodeFailed,
    /// A file that is not under any root, or cannot be resolved.
    FileNotFound,
    /// An `:ID:` that more than one heading under the roots has, or that a
    /// write would give a second heading.
    IdConflict,
    /// Editor configuration that cannot be found or read for an import.
    ImportFailed,
    /// An agenda file entry without the `.org` extension.
    InvalidAgendaFile,
    /// An agenda file pattern that is not a valid glob, or an absolute entry
    /// that is not an exact file under a root.
    InvalidAgendaPattern,
    /// A command line, or an MCP tool call's arguments, that do not parse:
    /// an unknown flag or argument, a missing value, a value of the wrong
    /// type.
    InvalidArguments,
    /// A batch of edits that lists one item twice.
    InvalidBatch,
    /// A capture body line that would start a heading.
    InvalidBody,
    /// A capture line whose title is empty.
    InvalidCapture,
    /// A config file that is missing, unreadable or holds an invalid value,
    /// or a config or data directory that cannot be determined.
    InvalidConfig,
    /// A date or time that does not parse; the message lists the forms it
    /// takes.
    InvalidDate,
    /// An edit that would change nothing: it names no change, or the item's
    /// bytes would stay as they are.
    InvalidEdit,
    /// A change to, or removal of, a heading's existing `:ID:`.
    InvalidIdChange,
    /// Imported editor configuration agd cannot turn into a config that
    /// loads.
    InvalidImport,
    /// A journal line that is not a record, or an inverse patch that does
    /// not restore the bytes the entry recorded.
    InvalidJournal,
    /// A review or undo of a journal entry in the wrong state: a review of
    /// one not awaiting review, an undo of one that is not a committed
    /// change.
    InvalidJournalState,
    /// A tags/TODO match expression that does not parse.
    InvalidMatch,
    /// A log note of several lines.
    InvalidNote,
    /// A patch that overlaps another, runs past the source or splits a UTF-8
    /// character.
    InvalidPatch,
    /// A path agd will not touch: with a `..` segment, outside the roots, a
    /// symlink to write through, without a parent, or not a regular file.
    InvalidPath,
    /// An MCP `plan` call with neither a scheduled nor a deadline date.
    InvalidPlan,
    /// A priority outside the file's range.
    InvalidPriority,
    /// A capture template prompt with no answer, or an answer of several
    /// lines.
    InvalidPrompt,
    /// A property key outside `[A-Za-z0-9_-]`, or a value of several lines.
    InvalidProperty,
    /// An org-ql query that does not parse; the message lists the supported
    /// predicates.
    InvalidQuery,
    /// An agenda range that ends before it starts.
    InvalidRange,
    /// An item ref that is neither an ID nor `file:line#sha256:digest`.
    InvalidRef,
    /// A refile into the item itself or its subtree, or one that would leave
    /// a heading without stars.
    InvalidRefile,
    /// A repeater that does not parse, repeats every 0 units, or repeats past
    /// the calendar.
    InvalidRepeater,
    /// No roots, or a root that cannot be resolved, is not a directory or is
    /// listed twice.
    InvalidRoot,
    /// An agenda span that is not day, week, month or a number of days.
    InvalidSpan,
    /// A tag with characters outside Org's tag characters.
    InvalidTag,
    /// A capture template agd cannot use: one with a `%(sexp)` form, which
    /// agd never runs, or one with neither a file nor an ID target.
    InvalidTemplate,
    /// A title that is not one line, that is cleared, or that the parser
    /// would read as a TODO keyword, priority cookie or tags.
    InvalidTitle,
    /// A TODO state the item's file does not define.
    InvalidTodo,
    /// An org file that is not UTF-8.
    InvalidUtf8,
    /// A view group's effort bound that is not a duration.
    InvalidView,
    /// A file system operation that failed; the message names the path.
    IoFailed,
    /// A write met an interrupted earlier write whose file changed since.
    JournalConflict,
    /// A journal record that cannot be encoded.
    JournalFailed,
    /// A journal entry or transaction id that is not in the journal, or no
    /// entry that `agd undo` may undo.
    JournalNotFound,
    /// A write lock that cannot be opened or taken.
    LockFailed,
    /// A file agd cannot read faithfully: the Org parser would not
    /// reproduce it byte for byte, its text does not read as agd expects
    /// (an unterminated property drawer), or a written heading cannot be
    /// found again.
    ParseFailed,
    /// A relative path more than one root could hold.
    PathConflict,
    /// A ref whose heading changed, moved out of the agenda files or no
    /// longer exists since it was read; the error's `current` is the item
    /// as it reads now, when there is one.
    RefConflict,
    /// An undo whose change was edited since; nothing is written.
    UndoConflict,
    /// An agent profile name that is not configured.
    UnknownProfile,
    /// A capture template key that is not configured.
    UnknownTemplate,
    /// A view key that names no view, or `--view` without a key when no
    /// start_view is set.
    UnknownView,
    /// A directory under a root that cannot be listed.
    WalkFailed,
}

impl ErrorCode {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::CaptureTargetNotFound => "capture_target_not_found",
            Self::CommandFailed => "command_failed",
            Self::EditorConflict => "editor_conflict",
            Self::EncodeFailed => "encode_failed",
            Self::FileNotFound => "file_not_found",
            Self::IdConflict => "id_conflict",
            Self::ImportFailed => "import_failed",
            Self::InvalidAgendaFile => "invalid_agenda_file",
            Self::InvalidAgendaPattern => "invalid_agenda_pattern",
            Self::InvalidArguments => "invalid_arguments",
            Self::InvalidBatch => "invalid_batch",
            Self::InvalidBody => "invalid_body",
            Self::InvalidCapture => "invalid_capture",
            Self::InvalidConfig => "invalid_config",
            Self::InvalidDate => "invalid_date",
            Self::InvalidEdit => "invalid_edit",
            Self::InvalidIdChange => "invalid_id_change",
            Self::InvalidImport => "invalid_import",
            Self::InvalidJournal => "invalid_journal",
            Self::InvalidJournalState => "invalid_journal_state",
            Self::InvalidMatch => "invalid_match",
            Self::InvalidNote => "invalid_note",
            Self::InvalidPatch => "invalid_patch",
            Self::InvalidPath => "invalid_path",
            Self::InvalidPlan => "invalid_plan",
            Self::InvalidPriority => "invalid_priority",
            Self::InvalidPrompt => "invalid_prompt",
            Self::InvalidProperty => "invalid_property",
            Self::InvalidQuery => "invalid_query",
            Self::InvalidRange => "invalid_range",
            Self::InvalidRef => "invalid_ref",
            Self::InvalidRefile => "invalid_refile",
            Self::InvalidRepeater => "invalid_repeater",
            Self::InvalidRoot => "invalid_root",
            Self::InvalidSpan => "invalid_span",
            Self::InvalidTag => "invalid_tag",
            Self::InvalidTemplate => "invalid_template",
            Self::InvalidTitle => "invalid_title",
            Self::InvalidTodo => "invalid_todo",
            Self::InvalidUtf8 => "invalid_utf8",
            Self::InvalidView => "invalid_view",
            Self::IoFailed => "io_failed",
            Self::JournalConflict => "journal_conflict",
            Self::JournalFailed => "journal_failed",
            Self::JournalNotFound => "journal_not_found",
            Self::LockFailed => "lock_failed",
            Self::ParseFailed => "parse_failed",
            Self::PathConflict => "path_conflict",
            Self::RefConflict => "ref_conflict",
            Self::UndoConflict => "undo_conflict",
            Self::UnknownProfile => "unknown_profile",
            Self::UnknownTemplate => "unknown_template",
            Self::UnknownView => "unknown_view",
            Self::WalkFailed => "walk_failed",
        }
    }
}

impl fmt::Display for ErrorCode {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(self.as_str())
    }
}

#[derive(Debug)]
pub struct AgdError {
    code: ErrorCode,
    response: Box<ErrorResponse>,
    source: Option<Box<dyn std::error::Error + Send + Sync>>,
}

impl AgdError {
    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
        Self {
            code,
            response: Box::new(ErrorResponse {
                schema: ERROR_SCHEMA.to_string(),
                code: code.as_str().to_string(),
                message: message.into(),
                current: None,
            }),
            source: None,
        }
    }

    /// The same error with `context: ` before its message, such as which
    /// of several targets it is about.
    pub fn with_context(mut self, context: &str) -> Self {
        self.response.message = format!("{context}: {}", self.response.message);
        self
    }

    pub fn with_current(mut self, current: Item) -> Self {
        self.response.current = Some(current);
        self
    }

    pub fn with_source(
        code: ErrorCode,
        message: impl Into<String>,
        source: impl std::error::Error + Send + Sync + 'static,
    ) -> Self {
        Self {
            source: Some(Box::new(source)),
            ..Self::new(code, message)
        }
    }

    pub fn code(&self) -> ErrorCode {
        self.code
    }

    pub fn response(&self) -> &ErrorResponse {
        &self.response
    }

    pub fn into_response(self) -> ErrorResponse {
        *self.response
    }
}

impl fmt::Display for AgdError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(&self.response.message)
    }
}

impl std::error::Error for AgdError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        self.source
            .as_deref()
            .map(|source| source as &(dyn std::error::Error + 'static))
    }
}

impl From<std::io::Error> for AgdError {
    fn from(error: std::io::Error) -> Self {
        Self::with_source(ErrorCode::IoFailed, error.to_string(), error)
    }
}