1use 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
30struct 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 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 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}