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}