1use std::path::{Path, PathBuf};
10
11use chrono::{Local, NaiveDateTime};
12
13use crate::{
14 AgdError, ErrorCode, IndexedDocument, Item, MUTATION_SCHEMA, Mutation, RootSet, Settings,
15};
16
17use super::patch::{StoredPatch, unified_diff};
18
19#[derive(Clone, Debug)]
20pub struct WriteContext {
21 pub actor: String,
22 pub project: String,
23 pub now: Option<NaiveDateTime>,
26}
27
28impl WriteContext {
29 pub fn org_now(&self) -> NaiveDateTime {
30 self.now.unwrap_or_else(|| Local::now().naive_local())
31 }
32
33 pub fn is_agent(&self) -> bool {
34 agent_name(&self.actor).is_some()
35 }
36}
37
38const AGENT_ACTOR_PREFIX: &str = "mcp:";
41
42pub fn agent_name(actor: &str) -> Option<&str> {
44 actor.strip_prefix(AGENT_ACTOR_PREFIX)
45}
46
47pub fn agent_actor(client_name: &str) -> String {
51 let name = client_name
52 .strip_suffix("-mcp-client")
53 .filter(|name| !name.is_empty())
54 .unwrap_or(client_name);
55 format!("{AGENT_ACTOR_PREFIX}{name}")
56}
57
58#[derive(Debug)]
59pub struct WriteEngine {
60 pub(super) settings: Settings,
61 pub(super) roots: RootSet,
62 pub(super) journal_path: PathBuf,
63}
64
65pub(super) const DRY_RUN_JOURNAL_ID: &str = "dry-run";
67
68pub(super) struct PreparedFile {
71 pub(super) path: PathBuf,
72 pre_image: String,
73 pub(super) post_image: String,
74 patches: Vec<StoredPatch>,
75 pub(super) diff: String,
76}
77
78impl PreparedFile {
79 pub(super) fn new(
80 path: PathBuf,
81 pre_image: String,
82 post_image: String,
83 patches: Vec<StoredPatch>,
84 ) -> Self {
85 let diff = unified_diff(&path, &pre_image, &post_image);
86 Self {
87 path,
88 pre_image,
89 post_image,
90 patches,
91 diff,
92 }
93 }
94
95 pub(super) fn journal_image<'a>(&'a self, item_ref: Option<&'a str>) -> JournalImage<'a> {
96 JournalImage {
97 path: &self.path,
98 item_ref,
99 pre_image: &self.pre_image,
100 post_image: &self.post_image,
101 patches: &self.patches,
102 diff: &self.diff,
103 }
104 }
105}
106
107pub(super) struct JournalImage<'a> {
108 pub(super) path: &'a Path,
109 pub(super) item_ref: Option<&'a str>,
110 pub(super) pre_image: &'a str,
111 pub(super) post_image: &'a str,
112 pub(super) patches: &'a [StoredPatch],
113 pub(super) diff: &'a str,
114}
115
116impl WriteEngine {
117 pub fn new(settings: Settings, journal_path: PathBuf) -> Result<Self, AgdError> {
121 let roots = RootSet::new(&settings.roots, &settings.agenda_files)?;
122 Ok(Self {
123 settings,
124 roots,
125 journal_path,
126 })
127 }
128
129 pub fn with_default_journal(settings: Settings) -> Result<Self, AgdError> {
130 Self::new(settings, crate::path::data_dir()?.join("journal.jsonl"))
131 }
132}
133
134pub(super) fn parse_text(
135 path: &Path,
136 text: &str,
137 settings: &Settings,
138) -> Result<IndexedDocument, AgdError> {
139 IndexedDocument::parse(path.to_path_buf(), text.as_bytes().to_vec(), settings)
140}
141
142pub(super) fn parse_updated_item(
143 path: &Path,
144 source: &str,
145 line_hint: usize,
146 settings: &Settings,
147) -> Result<Item, AgdError> {
148 let document =
149 IndexedDocument::parse(path.to_path_buf(), source.as_bytes().to_vec(), settings)?;
150 document
151 .headline_at_line(line_hint)
152 .or_else(|| {
153 document
154 .headlines()
155 .iter()
156 .min_by_key(|headline| headline.item.line.abs_diff(line_hint))
157 })
158 .map(|headline| headline.item.clone())
159 .ok_or_else(|| AgdError::new(ErrorCode::ParseFailed, "write produced no target headline"))
160}
161
162pub(super) fn mutation(item: Item, diff: String, journal_id: &str) -> Mutation {
164 Mutation {
165 schema: MUTATION_SCHEMA.to_string(),
166 ok: true,
167 item,
168 diff,
169 journal_id: journal_id.to_string(),
170 repeated: None,
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use std::collections::BTreeMap;
177 use std::fs;
178
179 use super::*;
180 use crate::CaptureDraft;
181 use crate::write::test_support::{context, first_ref, settings, state_change, tree};
182
183 #[test]
184 fn dry_runs_create_no_directory_lock_journal_or_file() {
185 let org = tempfile::tempdir().unwrap();
186 let data = tempfile::tempdir().unwrap();
187 fs::write(org.path().join("tasks.org"), "* TODO Task\n").unwrap();
188 let mut settings = settings(org.path());
189 settings.captures[0].file = Some("new/inbox.org".to_string());
190 let engine = WriteEngine::new(settings.clone(), data.path().join("journal.jsonl")).unwrap();
191 let draft = CaptureDraft {
192 title: "Preview only".to_string(),
193 ..CaptureDraft::default()
194 };
195 let preview = engine
196 .capture("t", &draft, "", &BTreeMap::new(), true, &context())
197 .unwrap();
198 assert!(
199 preview.diff.contains("TODO Preview only"),
200 "{}",
201 preview.diff
202 );
203 let item_ref = first_ref(&settings);
204 let done = state_change("DONE");
205 engine.edit(&item_ref, &done, true, &context()).unwrap();
206 assert_eq!(tree(org.path()), ["tasks.org"]);
207 assert!(tree(data.path()).is_empty(), "{:?}", tree(data.path()));
208 }
209}