Skip to main content

agd_core/write/
capture.rs

1//! `WriteEngine::capture`: a capture through a template, from the target file
2//! (a configured file, or the file of an `id` target, inside the roots) to the
3//! rendered entry inserted and journaled; and the agent inbox gate, which sends
4//! an agent's capture to `agent_inbox` without dates.
5//!
6//! The capture is computed once before any directory is created and again
7//! under the write lock, so a refused capture leaves nothing behind. Where the
8//! entry goes is `placement`'s; the template text is `crate::capture_template`'s.
9
10use std::collections::BTreeMap;
11use std::fs;
12use std::path::{Path, PathBuf};
13
14use chrono::NaiveDateTime;
15
16use crate::{
17    AgdError, CaptureDraft, CaptureTemplate, ErrorCode, Item, Mutation, Settings, WorkspaceIndex,
18    render_capture_template,
19};
20
21use super::engine::{
22    DRY_RUN_JOURNAL_ID, PreparedFile, WriteContext, WriteEngine, mutation, parse_text,
23    parse_updated_item,
24};
25use super::file::{read_org_file, refuse_emacs_lock};
26use super::journal::JournalOperation;
27use super::org_edit::align_tags;
28use super::patch::{apply_patches, changed_lines_patch, line_ending};
29use super::placement::{heading_line_starts, insert_capture_entry, place_capture};
30use super::refile::relevel_subtree;
31use super::validation::{refuse_used_id, validate_capture_for_file, validate_capture_input};
32
33/// What a capture renders, gathered so it can be prepared twice: once to
34/// validate and preview, once under the write lock.
35struct CaptureInput<'a> {
36    template: &'a CaptureTemplate,
37    draft: &'a CaptureDraft,
38    body: &'a str,
39    prompts: &'a BTreeMap<String, String>,
40    now: NaiveDateTime,
41}
42
43struct PreparedCapture {
44    file: PreparedFile,
45    item: Item,
46}
47
48impl WriteEngine {
49    /// Captures through a template. A dry run renders the same diff and
50    /// touches nothing: no directory, lock, journal entry or file. A real
51    /// capture is computed once before any directory is created, so a
52    /// refused capture leaves nothing behind either.
53    pub fn capture(
54        &self,
55        template_key: &str,
56        draft: &CaptureDraft,
57        body: &str,
58        prompts: &BTreeMap<String, String>,
59        dry_run: bool,
60        context: &WriteContext,
61    ) -> Result<Mutation, AgdError> {
62        let template = self.settings.find_capture_template(template_key)?;
63        let (template, draft) = apply_agent_capture_gate(template, draft, &self.settings, context);
64        validate_capture_input(&draft, body, prompts)?;
65        let input = CaptureInput {
66            template: &template,
67            draft: &draft,
68            body,
69            prompts,
70            now: context.org_now(),
71        };
72        let path = self.capture_path(&template)?;
73        let preview = self.prepare_capture(&path, &input)?;
74        refuse_emacs_lock(&path)?;
75        if dry_run {
76            return Ok(mutation(
77                preview.item,
78                preview.file.diff,
79                DRY_RUN_JOURNAL_ID,
80            ));
81        }
82
83        if let Some(parent) = path.parent() {
84            fs::create_dir_all(parent).map_err(AgdError::from)?;
85        }
86        self.roots.resolve_write(&path, true)?;
87        let _locks = self.lock_paths(std::slice::from_ref(&path))?;
88        let prepared = self.prepare_capture(&path, &input)?;
89        let record = self.commit_image(
90            prepared.file.journal_image(Some(&prepared.item.item_ref)),
91            JournalOperation::Capture,
92            context,
93        )?;
94        Ok(mutation(prepared.item, prepared.file.diff, &record.id))
95    }
96
97    /// The canonical path a template captures into, confined to the roots,
98    /// whether or not the file or its directories exist yet. An `id` target
99    /// is the file of the one heading with that ID.
100    fn capture_path(&self, template: &CaptureTemplate) -> Result<PathBuf, AgdError> {
101        if let Some(id) = &template.id {
102            let index = WorkspaceIndex::load(self.settings.clone())?;
103            let (document, _) = index
104                .resolve(id)
105                .map_err(|error| capture_id_error(&template.key, id, error))?;
106            return self.roots.resolve_write(document.path(), false);
107        }
108        let file = template.file.as_deref().ok_or_else(|| {
109            AgdError::new(
110                ErrorCode::InvalidTemplate,
111                format!(
112                    "capture template '{}' names no file or id target",
113                    template.key
114                ),
115            )
116        })?;
117        // `~` expands as in roots, before the path is confined to them.
118        let file = shellexpand::tilde(file);
119        let path = self.roots.resolve_write(Path::new(file.as_ref()), true)?;
120        // Canonical like every indexed path, so the journal, refs and the
121        // index agree (on macOS /var is /private/var).
122        let path = crate::path::resolve_existing_prefix(&path)?;
123        self.roots.resolve_write(&path, true)
124    }
125
126    /// The capture's insertion into `path` as it is on disk now.
127    fn prepare_capture(
128        &self,
129        path: &Path,
130        input: &CaptureInput<'_>,
131    ) -> Result<PreparedCapture, AgdError> {
132        let pre_image = read_org_file(path)?;
133        validate_capture_for_file(input.draft, &parse_text(path, &pre_image, &self.settings)?)?;
134        let rendered = render_capture_template(
135            input.template,
136            input.draft,
137            input.body,
138            input.prompts,
139            input.now,
140        )?;
141        let newline = line_ending(&pre_image);
142        let rendered =
143            append_body_without_escape(rendered, &input.template.template, input.body, newline);
144        let placement = place_capture(
145            path,
146            &pre_image,
147            input.template,
148            input.now.date(),
149            &self.settings,
150        )?;
151        let entry = capture_entry(&rendered, placement.level, newline)?;
152        let entry = align_first_line_tags(&entry, self.settings.tags_column);
153        let (post_image, entry_start) =
154            insert_capture_entry(&placement.text, placement.offset, &entry, newline);
155        let patch = changed_lines_patch(&pre_image, &post_image);
156        let (post_image, stored) = apply_patches(&pre_image, vec![patch])?;
157        let line = post_image[..entry_start]
158            .bytes()
159            .filter(|byte| *byte == b'\n')
160            .count()
161            + 1;
162        let item = parse_updated_item(path, &post_image, line, &self.settings)?;
163        if let Some(id) = &item.id {
164            refuse_used_id(id, &WorkspaceIndex::load(self.settings.clone())?)?;
165        }
166        Ok(PreparedCapture {
167            file: PreparedFile::new(path.to_path_buf(), pre_image, post_image, stored),
168            item,
169        })
170    }
171}
172
173/// A body given without a `%i` in the template becomes the entry's body,
174/// after the template's text and any properties drawer, instead of being
175/// dropped.
176fn append_body_without_escape(
177    rendered: String,
178    template: &str,
179    body: &str,
180    newline: &str,
181) -> String {
182    let body = body.trim_end_matches(['\n', '\r']);
183    if body.is_empty() || template_inserts_body(template) {
184        return rendered;
185    }
186    let mut entry = rendered.trim_end_matches(['\n', '\r']).to_string();
187    entry.push_str(newline);
188    entry.push_str(body);
189    entry
190}
191
192/// Whether the template has a `%i` escape (`%%i` is a literal `%i`).
193fn template_inserts_body(template: &str) -> bool {
194    let mut characters = template.chars();
195    while let Some(character) = characters.next() {
196        if character == '%' && characters.next() == Some('i') {
197            return true;
198        }
199    }
200    false
201}
202
203/// The entry's headline with its tags aligned to `tags_column`, as an edit
204/// writes a headline.
205fn align_first_line_tags(entry: &str, column: i64) -> String {
206    let line_end = entry.find(['\r', '\n']).unwrap_or(entry.len());
207    let (headline, rest) = entry.split_at(line_end);
208    let Some((head, tags)) = crate::document::split_trailing_tags(headline) else {
209        return entry.to_string();
210    };
211    format!(
212        "{}{rest}",
213        align_tags(format!("{head} {tags}"), true, column)
214    )
215}
216
217/// The rendered template as an entry at `level`: every heading in it moved
218/// by the same number of levels (org-paste-subtree), a template without a
219/// leading heading made one (`* `), ending in a newline.
220fn capture_entry(rendered: &str, level: usize, newline: &str) -> Result<String, AgdError> {
221    let rendered = if heading_line_starts(rendered, (0, 1)).is_empty() {
222        format!("* {rendered}")
223    } else {
224        rendered.to_string()
225    };
226    let first_level = rendered.bytes().take_while(|byte| *byte == b'*').count();
227    let mut entry = relevel_subtree(&rendered, level, first_level)?;
228    if !entry.ends_with('\n') {
229        entry.push_str(newline);
230    }
231    Ok(entry)
232}
233
234fn capture_id_error(template_key: &str, id: &str, error: AgdError) -> AgdError {
235    if error.code() == ErrorCode::IdConflict {
236        return error;
237    }
238    AgdError::new(
239        ErrorCode::CaptureTargetNotFound,
240        format!("capture template '{template_key}' targets ID '{id}', which no agenda heading has"),
241    )
242}
243
244// With agent_inbox set, an agent capture always lands in that template
245// without planning, so it reaches the day only through triage.
246fn apply_agent_capture_gate(
247    template: &CaptureTemplate,
248    draft: &CaptureDraft,
249    settings: &Settings,
250    context: &WriteContext,
251) -> (CaptureTemplate, CaptureDraft) {
252    let inbox_key = match settings.agent_inbox.as_deref() {
253        Some(key) if context.is_agent() => key,
254        _ => return (template.clone(), draft.clone()),
255    };
256    let inbox = settings
257        .capture_template(inbox_key)
258        .cloned()
259        .unwrap_or_else(|| template.clone());
260    let mut draft = draft.clone();
261    draft.scheduled = None;
262    draft.deadline = None;
263    (inbox, draft)
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use crate::write::test_support::{context, settings, tree};
270    use crate::{PlanningTimestamp, WriteEngine};
271
272    #[test]
273    fn capture_journal_records_the_new_item_ref() {
274        let temp = tempfile::tempdir().unwrap();
275        let mut settings = settings(temp.path());
276        settings.captures[0].file =
277            Some(temp.path().join("inbox.org").to_string_lossy().into_owned());
278        let engine = WriteEngine::new(settings, temp.path().join("journal.jsonl")).unwrap();
279        let draft = CaptureDraft {
280            title: "New".to_string(),
281            ..CaptureDraft::default()
282        };
283        let mutation = engine
284            .capture("t", &draft, "", &BTreeMap::new(), false, &context())
285            .unwrap();
286        let record = engine
287            .journal()
288            .unwrap()
289            .into_iter()
290            .find(|record| record.id == mutation.journal_id)
291            .unwrap();
292        assert_eq!(
293            record.item_ref.as_deref(),
294            Some(mutation.item.item_ref.as_str())
295        );
296        assert_eq!(record.operation, Some(JournalOperation::Capture));
297        assert_eq!(record.operation_label(), "capture");
298        assert_eq!(
299            Path::new(&record.file),
300            fs::canonicalize(temp.path().join("inbox.org")).unwrap()
301        );
302    }
303
304    #[test]
305    fn a_refused_capture_creates_no_directory() {
306        let org = tempfile::tempdir().unwrap();
307        let data = tempfile::tempdir().unwrap();
308        let mut settings = settings(org.path());
309        settings.captures[0].file = Some("new/inbox.org".to_string());
310        settings.captures[0].template = "* TODO %?\n%^{Missing}".to_string();
311        let engine = WriteEngine::new(settings, data.path().join("journal.jsonl")).unwrap();
312        let draft = CaptureDraft {
313            title: "Refused".to_string(),
314            ..CaptureDraft::default()
315        };
316        let error = engine
317            .capture("t", &draft, "", &BTreeMap::new(), false, &context())
318            .unwrap_err();
319        assert_eq!(error.code().as_str(), "invalid_prompt");
320        assert!(tree(org.path()).is_empty());
321        assert!(tree(data.path()).is_empty());
322    }
323
324    #[test]
325    fn a_body_is_appended_when_the_template_has_no_escape_for_it() {
326        let temp = tempfile::tempdir().unwrap();
327        let mut settings = settings(temp.path());
328        settings.captures = vec![
329            CaptureTemplate {
330                key: "t".to_string(),
331                file: Some("inbox.org".to_string()),
332                heading: None,
333                template: "* TODO %?\n:PROPERTIES:\n:SOURCE: agent\n:END:".to_string(),
334                ..CaptureTemplate::default()
335            },
336            CaptureTemplate {
337                key: "i".to_string(),
338                file: Some("inbox.org".to_string()),
339                heading: None,
340                template: "* TODO %?\nContext: %i\nLiteral 100%%i".to_string(),
341                ..CaptureTemplate::default()
342            },
343        ];
344        let engine = WriteEngine::new(settings, temp.path().join("journal.jsonl")).unwrap();
345        let draft = CaptureDraft {
346            title: "Follow up".to_string(),
347            ..CaptureDraft::default()
348        };
349        engine
350            .capture(
351                "t",
352                &draft,
353                "Line one\nLine two\n",
354                &BTreeMap::new(),
355                false,
356                &context(),
357            )
358            .unwrap();
359        engine
360            .capture("i", &draft, "inline", &BTreeMap::new(), false, &context())
361            .unwrap();
362        assert_eq!(
363            fs::read_to_string(temp.path().join("inbox.org")).unwrap(),
364            "* TODO Follow up\n:PROPERTIES:\n:SOURCE: agent\n:END:\nLine one\nLine two\n* TODO Follow up\nContext: inline\nLiteral 100%i\n"
365        );
366    }
367
368    #[test]
369    fn captured_tags_align_to_the_tags_column_like_edits() {
370        let temp = tempfile::tempdir().unwrap();
371        let mut settings = settings(temp.path());
372        settings.tags_column = -30;
373        settings.captures[0].file = Some("inbox.org".to_string());
374        settings.captures[0].heading = None;
375        settings.captures[0].template = "* TODO %?".to_string();
376        let engine = WriteEngine::new(settings, temp.path().join("journal.jsonl")).unwrap();
377        let draft = CaptureDraft {
378            title: "Renew".to_string(),
379            tags: vec!["admin".to_string()],
380            ..CaptureDraft::default()
381        };
382        engine
383            .capture("t", &draft, "", &BTreeMap::new(), false, &context())
384            .unwrap();
385        let written = fs::read_to_string(temp.path().join("inbox.org")).unwrap();
386        assert_eq!(
387            written,
388            format!("* TODO Renew{}:admin:\n", " ".repeat(30 - 12 - 7))
389        );
390    }
391
392    #[test]
393    fn inbox_gate_redirects_only_agent_captures_and_drops_their_planning() {
394        let temp = tempfile::tempdir().unwrap();
395        let mut settings = settings(temp.path());
396        settings.agent_inbox = Some("t".to_string());
397        settings.captures.push(CaptureTemplate {
398            key: "w".to_string(),
399            file: Some("work.org".to_string()),
400            heading: None,
401            template: "* TODO %?".to_string(),
402            ..CaptureTemplate::default()
403        });
404        let engine = WriteEngine::new(settings, temp.path().join("journal.jsonl")).unwrap();
405        let draft = CaptureDraft {
406            title: "Planned".to_string(),
407            scheduled: Some(PlanningTimestamp {
408                date: "2026-10-15".to_string(),
409                time: None,
410                end_time: None,
411                repeater: None,
412                warning: None,
413                raw: None,
414            }),
415            deadline: None,
416            tags: Vec::new(),
417            priority: None,
418            properties: BTreeMap::new(),
419        };
420        let agent = WriteContext {
421            actor: "mcp:claude-code".to_string(),
422            project: "fixture".to_string(),
423            now: None,
424        };
425        let agent_capture = engine
426            .capture("w", &draft, "", &BTreeMap::new(), true, &agent)
427            .unwrap();
428        assert!(agent_capture.item.file.ends_with("inbox.org"));
429        assert!(!agent_capture.diff.contains("SCHEDULED"));
430
431        let human_capture = engine
432            .capture("w", &draft, "", &BTreeMap::new(), true, &context())
433            .unwrap();
434        assert!(human_capture.item.file.ends_with("work.org"));
435        assert!(human_capture.diff.contains("SCHEDULED: <2026-10-15 Thu>"));
436    }
437
438    #[test]
439    fn capture_preview_matches_committed_bytes() {
440        let temp = tempfile::tempdir().unwrap();
441        let settings = settings(temp.path());
442        let engine = WriteEngine::new(settings, temp.path().join("journal.jsonl")).unwrap();
443        let draft = CaptureDraft {
444            title: "Captured".to_string(),
445            scheduled: None,
446            deadline: None,
447            tags: Vec::new(),
448            priority: None,
449            properties: BTreeMap::new(),
450        };
451        let preview = engine
452            .capture("t", &draft, "", &BTreeMap::new(), true, &context())
453            .unwrap();
454        let committed = engine
455            .capture("t", &draft, "", &BTreeMap::new(), false, &context())
456            .unwrap();
457        assert_eq!(preview.diff, committed.diff);
458        assert!(
459            fs::read_to_string(temp.path().join("inbox.org"))
460                .unwrap()
461                .contains("* TODO Captured\n")
462        );
463    }
464}