Skip to main content

agd_core/write/
engine.rs

1//! `WriteEngine` and `WriteContext`, and what every write shares: the dry-run
2//! journal id, a computed file image (`PreparedFile`) and its journal image,
3//! parsing written text again, and the `Mutation` result.
4//!
5//! The operations live by concern: `edit`, `capture`, `refile`, `undo`,
6//! `journal` (review and interrupted writes) and `file` (locks and atomic
7//! writes). Opening an engine only reads.
8
9use 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    /// Clock for timestamps written into Org text (CLOSED, LOGBOOK, %U);
24    /// None uses the local time. Tests and oracles pin it.
25    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
38/// How an MCP client's writes are journaled: `mcp:<client name>`. Every
39/// other actor is a person using the CLI or the TUI.
40const AGENT_ACTOR_PREFIX: &str = "mcp:";
41
42/// The agent an actor names, `codex` for `mcp:codex`; None for a person.
43pub fn agent_name(actor: &str) -> Option<&str> {
44    actor.strip_prefix(AGENT_ACTOR_PREFIX)
45}
46
47/// The actor an MCP client's writes are journaled under: `mcp:<client>`,
48/// without the `-mcp-client` suffix Codex sends (`codex-mcp-client` is
49/// `mcp:codex`); other names are kept as sent.
50pub 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
65/// The `journal_id` of a dry-run mutation, which writes no journal entry.
66pub(super) const DRY_RUN_JOURNAL_ID: &str = "dry-run";
67
68/// One file's computed write: its bytes now, its bytes after, and the
69/// journal patches and diff between them.
70pub(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    /// An engine writing under the configured roots and journaling to
118    /// `journal_path`. Opening it only reads; interrupted writes are settled
119    /// by the next write to their file, or by `resolve_conflicts`.
120    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
162/// A successful mutation result.
163pub(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}