Skip to main content

agd_core/write/
refile.rs

1//! `WriteEngine::refile`: moving an item's subtree under another heading, in
2//! one file or across two, with its headings relevelled.
3//!
4//! Across files it is one journal transaction that writes the target first, so
5//! an interruption can leave the item in both files but never in neither. An
6//! item cannot be refiled into itself or its descendants.
7
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12use crate::{
13    AgdError, ByteSpan, ErrorCode, IndexedDocument, Item, REFILE_SCHEMA, Settings, WorkspaceIndex,
14};
15
16use super::engine::{DRY_RUN_JOURNAL_ID, PreparedFile, WriteContext, WriteEngine};
17use super::file::refuse_emacs_lock;
18use super::journal::JournalOperation;
19use super::patch::{Patch, apply_patches, line_ending};
20
21#[derive(Clone, Debug, Deserialize, Serialize)]
22pub struct RefileResult {
23    pub schema: String,
24    pub ok: bool,
25    pub item: Item,
26    pub diffs: Vec<String>,
27    pub journal_ids: Vec<String>,
28}
29
30/// A refile's file images: one, or the source and then the target.
31struct PreparedRefile {
32    files: Vec<PreparedFile>,
33    item: Item,
34}
35
36impl PreparedRefile {
37    fn paths(&self) -> Vec<PathBuf> {
38        let mut paths: Vec<PathBuf> = self.files.iter().map(|file| file.path.clone()).collect();
39        paths.sort();
40        paths
41    }
42
43    fn result(self, journal_ids: Vec<String>) -> RefileResult {
44        RefileResult {
45            schema: REFILE_SCHEMA.to_string(),
46            ok: true,
47            item: self.item,
48            diffs: self.files.into_iter().map(|file| file.diff).collect(),
49            journal_ids,
50        }
51    }
52}
53
54impl WriteEngine {
55    /// Moves an item's subtree under another heading, in one file or
56    /// across two as one journal transaction (the target written first, so
57    /// an interruption never loses the item). A dry run touches nothing.
58    pub fn refile(
59        &self,
60        item_ref: &str,
61        target_ref: &str,
62        dry_run: bool,
63        context: &WriteContext,
64    ) -> Result<RefileResult, AgdError> {
65        let initial = WorkspaceIndex::load(self.settings.clone())?;
66        let preview = self.prepare_refile(&initial, item_ref, target_ref)?;
67        let paths = preview.paths();
68        for path in &paths {
69            self.roots.resolve_write(path, false)?;
70            refuse_emacs_lock(path)?;
71        }
72        if dry_run {
73            return Ok(preview.result(vec![DRY_RUN_JOURNAL_ID.to_string()]));
74        }
75
76        let _locks = self.lock_paths(&paths)?;
77        let fresh = WorkspaceIndex::load(self.settings.clone())?;
78        let prepared = self.prepare_refile(&fresh, item_ref, target_ref)?;
79        if prepared.paths() != paths {
80            return Err(AgdError::new(
81                ErrorCode::RefConflict,
82                "refile resolved to different files while acquiring the write locks",
83            ));
84        }
85        let journal_ids = match prepared.files.as_slice() {
86            [same_file] => vec![
87                self.commit_image(
88                    same_file.journal_image(Some(item_ref)),
89                    JournalOperation::Refile,
90                    context,
91                )?
92                .id,
93            ],
94            [source, target] => {
95                let records = self.commit_transaction(
96                    &[
97                        target.journal_image(Some(target_ref)),
98                        source.journal_image(Some(item_ref)),
99                    ],
100                    JournalOperation::Refile,
101                    context,
102                )?;
103                vec![records[1].id.clone(), records[0].id.clone()]
104            }
105            _ => unreachable!("a refile writes one or two files"),
106        };
107        Ok(prepared.result(journal_ids))
108    }
109
110    /// The refile's file images (source first) against `index`.
111    fn prepare_refile(
112        &self,
113        index: &WorkspaceIndex,
114        item_ref: &str,
115        target_ref: &str,
116    ) -> Result<PreparedRefile, AgdError> {
117        let (source_document, source_headline) = index.resolve(item_ref)?;
118        let (target_document, target_headline) = index.resolve(target_ref)?;
119        let same_file = source_document.path() == target_document.path();
120        if same_file
121            && target_headline.line_span.start >= source_headline.subtree_span.start
122            && target_headline.line_span.start < source_headline.subtree_span.end
123        {
124            return Err(AgdError::new(
125                ErrorCode::InvalidRefile,
126                "an item cannot be refiled into itself or one of its descendants",
127            ));
128        }
129        let moved = &source_document.source()
130            [source_headline.subtree_span.start..source_headline.subtree_span.end];
131        let moved = relevel_subtree(moved, target_headline.level + 1, source_headline.level)?;
132        let moved = normalize_insertion(
133            target_document.source(),
134            target_headline.subtree_span.end,
135            moved,
136        );
137        let removal = Patch {
138            span: source_headline.subtree_span,
139            replacement: String::new(),
140        };
141        let insertion = Patch {
142            span: ByteSpan {
143                start: target_headline.subtree_span.end,
144                end: target_headline.subtree_span.end,
145            },
146            replacement: moved,
147        };
148        let prepare = |document: &IndexedDocument, patches: Vec<Patch>| {
149            apply_patches(document.source(), patches).map(|(post_image, stored)| {
150                PreparedFile::new(
151                    document.path().to_path_buf(),
152                    document.source().to_string(),
153                    post_image,
154                    stored,
155                )
156            })
157        };
158        let files = if same_file {
159            vec![prepare(source_document, vec![removal, insertion])?]
160        } else {
161            vec![
162                prepare(source_document, vec![removal])?,
163                prepare(target_document, vec![insertion])?,
164            ]
165        };
166        let target_file = files.last().expect("a refile writes a file");
167        let item = find_refiled_item(
168            &target_file.path,
169            &target_file.post_image,
170            &source_headline.item,
171            &target_headline.item,
172            &self.settings,
173        )?;
174        Ok(PreparedRefile { files, item })
175    }
176}
177
178pub(super) fn relevel_subtree(
179    source: &str,
180    target_level: usize,
181    source_level: usize,
182) -> Result<String, AgdError> {
183    let delta = target_level as isize - source_level as isize;
184    let mut output = String::with_capacity(source.len());
185    for line in source.split_inclusive('\n') {
186        let stars = line.bytes().take_while(|byte| *byte == b'*').count();
187        if stars > 0
188            && line
189                .as_bytes()
190                .get(stars)
191                .is_some_and(u8::is_ascii_whitespace)
192        {
193            let level = stars as isize + delta;
194            if level < 1 {
195                return Err(AgdError::new(
196                    ErrorCode::InvalidRefile,
197                    "refile would produce an invalid headline level",
198                ));
199            }
200            output.push_str(&"*".repeat(level as usize));
201            output.push_str(&line[stars..]);
202        } else {
203            output.push_str(line);
204        }
205    }
206    Ok(output)
207}
208
209fn normalize_insertion(target: &str, offset: usize, moved: String) -> String {
210    let newline = line_ending(target);
211    let mut insertion = String::new();
212    if offset > 0 && !target[..offset].ends_with('\n') {
213        insertion.push_str(newline);
214    }
215    insertion.push_str(&moved);
216    if !insertion.ends_with('\n') {
217        insertion.push_str(newline);
218    }
219    insertion
220}
221
222fn find_refiled_item(
223    path: &Path,
224    source: &str,
225    moved: &Item,
226    target: &Item,
227    settings: &Settings,
228) -> Result<Item, AgdError> {
229    let document =
230        IndexedDocument::parse(path.to_path_buf(), source.as_bytes().to_vec(), settings)?;
231    if let Some(id) = &moved.id
232        && let Some(headline) = document
233            .headlines()
234            .iter()
235            .find(|headline| headline.item.id.as_ref() == Some(id))
236    {
237        return Ok(headline.item.clone());
238    }
239    let mut expected = target.olpath.clone();
240    expected.push(moved.title.clone());
241    document
242        .headlines()
243        .iter()
244        .find(|headline| headline.item.olpath == expected)
245        .map(|headline| headline.item.clone())
246        .ok_or_else(|| {
247            AgdError::new(
248                ErrorCode::ParseFailed,
249                "refiled item was not found at its target",
250            )
251        })
252}
253
254#[cfg(test)]
255mod tests {
256    use std::fs;
257
258    use super::*;
259    use crate::write::test_support::{context, settings};
260
261    #[test]
262    fn cross_file_refile_and_transaction_undo_are_exact() {
263        let temp = tempfile::tempdir().unwrap();
264        let source_path = temp.path().join("source.org");
265        let target_path = temp.path().join("target.org");
266        let source = "* Source\n** TODO Move me\n:PROPERTIES:\n:ID: move-id\n:END:\nbody\n* Keep\n";
267        let target = "* Destination\n:PROPERTIES:\n:ID: destination-id\n:END:\n";
268        fs::write(&source_path, source).unwrap();
269        fs::write(&target_path, target).unwrap();
270        let settings = settings(temp.path());
271        let engine = WriteEngine::new(settings, temp.path().join("journal-refile.jsonl")).unwrap();
272        let result = engine
273            .refile("move-id", "destination-id", false, &context())
274            .unwrap();
275        assert!(
276            !fs::read_to_string(&source_path)
277                .unwrap()
278                .contains("Move me")
279        );
280        assert!(
281            fs::read_to_string(&target_path)
282                .unwrap()
283                .contains("** TODO Move me\n")
284        );
285        engine.undo(&result.journal_ids[0], &context()).unwrap();
286        assert_eq!(fs::read_to_string(source_path).unwrap(), source);
287        assert_eq!(fs::read_to_string(target_path).unwrap(), target);
288    }
289}