1use 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
33struct 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 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 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 let file = shellexpand::tilde(file);
119 let path = self.roots.resolve_write(Path::new(file.as_ref()), true)?;
120 let path = crate::path::resolve_existing_prefix(&path)?;
123 self.roots.resolve_write(&path, true)
124 }
125
126 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
173fn 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
192fn 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
203fn 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
217fn 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
244fn 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}