1use crate::batch_tools::BatchTools;
14use crate::file_tools::{FileTools, NoteInfo, WriteMode};
15use crate::git_file_tools::{CachedRepo, CasCollisionFlush, GitFileTools, MoveWithLinksResult};
16use std::path::PathBuf;
17use std::sync::Arc;
18use turbovault_batch::{BatchOperation, BatchResult};
19use turbovault_core::prelude::*;
20use turbovault_git::{CommitHook, CommitLocks};
21
22use turbovault_vault::{EditResult, VaultManager};
23
24#[derive(Clone)]
27pub enum WriteTools {
28 Legacy { files: FileTools, batch: BatchTools },
31 Git(GitFileTools),
33}
34
35impl WriteTools {
36 pub fn is_git(&self) -> bool {
38 matches!(self, Self::Git(_))
39 }
40
41 pub fn legacy(manager: Arc<VaultManager>) -> Self {
44 Self::Legacy {
45 files: FileTools::new(Arc::clone(&manager)),
46 batch: BatchTools::new(manager),
47 }
48 }
49
50 pub fn git(
54 manager: Arc<VaultManager>,
55 vault_path: PathBuf,
56 commit_locks: Arc<CommitLocks>,
57 ) -> Self {
58 Self::Git(GitFileTools::new(manager, vault_path, commit_locks))
59 }
60
61 pub fn git_with_hook(
65 manager: Arc<VaultManager>,
66 vault_path: PathBuf,
67 commit_locks: Arc<CommitLocks>,
68 commit_hook: CommitHook,
69 ) -> Self {
70 Self::Git(GitFileTools::new_with_hook(
71 manager,
72 vault_path,
73 commit_locks,
74 commit_hook,
75 ))
76 }
77
78 pub fn git_with_hook_and_flush(
83 manager: Arc<VaultManager>,
84 vault_path: PathBuf,
85 commit_locks: Arc<CommitLocks>,
86 commit_hook: CommitHook,
87 flush_on_collision: CasCollisionFlush,
88 ) -> Self {
89 Self::Git(GitFileTools::new_with_hook_and_flush(
90 manager,
91 vault_path,
92 commit_locks,
93 commit_hook,
94 flush_on_collision,
95 ))
96 }
97
98 pub fn with_include_ignored(self, include_ignored: bool) -> Self {
105 match self {
106 Self::Git(g) => Self::Git(g.with_include_ignored(include_ignored)),
107 other => other,
108 }
109 }
110
111 pub fn with_cached_repo(self, cached_repo: CachedRepo) -> Self {
115 match self {
116 Self::Git(g) => Self::Git(g.with_cached_repo(cached_repo)),
117 other => other,
118 }
119 }
120
121 pub async fn read_file(&self, path: &str) -> Result<String> {
124 match self {
125 Self::Legacy { files, .. } => files.read_file(path).await,
126 Self::Git(g) => g.read_file(path).await,
127 }
128 }
129
130 pub async fn get_notes_info(&self, paths: &[String]) -> Result<Vec<NoteInfo>> {
131 match self {
132 Self::Legacy { files, .. } => files.get_notes_info(paths).await,
133 Self::Git(g) => g.get_notes_info(paths).await,
134 }
135 }
136
137 pub async fn write_file_with_mode(
140 &self,
141 path: &str,
142 content: &str,
143 mode: WriteMode,
144 expected_hash: Option<&str>,
145 ) -> Result<()> {
146 match self {
147 Self::Legacy { files, .. } => {
148 files
149 .write_file_with_mode(path, content, mode, expected_hash)
150 .await
151 }
152 Self::Git(g) => {
153 g.write_file_with_mode(path, content, mode, expected_hash)
154 .await
155 }
156 }
157 }
158
159 pub async fn write_file(&self, path: &str, content: &str) -> Result<()> {
160 match self {
161 Self::Legacy { files, .. } => files.write_file(path, content).await,
162 Self::Git(g) => g.write_file(path, content).await,
163 }
164 }
165
166 pub async fn create_file(&self, path: &str, content: &str) -> Result<()> {
179 match self {
180 Self::Legacy { files, .. } => files.write_file(path, content).await,
181 Self::Git(g) => g.create_file(path, content).await,
182 }
183 }
184
185 pub async fn write_file_with_mode_and_message(
193 &self,
194 path: &str,
195 content: &str,
196 mode: WriteMode,
197 expected_hash: Option<&str>,
198 message: &str,
199 ) -> Result<()> {
200 match self {
201 Self::Legacy { files, .. } => {
202 files
203 .write_file_with_mode(path, content, mode, expected_hash)
204 .await
205 }
206 Self::Git(g) => {
207 g.write_file_with_mode_and_message(path, content, mode, expected_hash, message)
208 .await
209 }
210 }
211 }
212
213 pub async fn create_file_with_message(
214 &self,
215 path: &str,
216 content: &str,
217 message: &str,
218 ) -> Result<()> {
219 match self {
220 Self::Legacy { files, .. } => files.write_file(path, content).await,
221 Self::Git(g) => g.create_file_with_message(path, content, message).await,
222 }
223 }
224
225 pub async fn edit_file_with_message(
226 &self,
227 path: &str,
228 edits: &str,
229 expected_hash: Option<&str>,
230 dry_run: bool,
231 message: &str,
232 ) -> Result<EditResult> {
233 match self {
234 Self::Legacy { files, .. } => {
235 files.edit_file(path, edits, expected_hash, dry_run).await
236 }
237 Self::Git(g) => {
238 g.edit_file_with_message(path, edits, expected_hash, dry_run, message)
239 .await
240 }
241 }
242 }
243
244 pub async fn delete_file_with_hash_and_message(
245 &self,
246 path: &str,
247 expected_hash: Option<&str>,
248 message: &str,
249 ) -> Result<()> {
250 match self {
251 Self::Legacy { files, .. } => files.delete_file_with_hash(path, expected_hash).await,
252 Self::Git(g) => {
253 g.delete_file_with_hash_and_message(path, expected_hash, message)
254 .await
255 }
256 }
257 }
258
259 pub async fn move_file_with_hash_and_message(
260 &self,
261 from: &str,
262 to: &str,
263 expected_hash: Option<&str>,
264 message: &str,
265 ) -> Result<()> {
266 match self {
267 Self::Legacy { files, .. } => files.move_file_with_hash(from, to, expected_hash).await,
268 Self::Git(g) => {
269 g.move_file_with_hash_and_message(from, to, expected_hash, message)
270 .await
271 }
272 }
273 }
274
275 pub async fn list_inbound_backlinks(&self, path: &str) -> Result<Vec<String>> {
280 match self {
281 Self::Git(g) => g.list_inbound_backlinks(path).await,
282 Self::Legacy { files, .. } => {
283 let bls = files
284 .manager
285 .get_backlinks(std::path::Path::new(path))
286 .await?;
287 let vault_root = files.manager.vault_path().clone();
288 let mut out = Vec::new();
289 for full in bls {
290 let rel = full
291 .strip_prefix(&vault_root)
292 .map(|p| p.to_path_buf())
293 .unwrap_or_else(|_| full.clone());
294 if let Some(s) = rel.to_str() {
295 out.push(s.to_string());
296 }
297 }
298 Ok(out)
299 }
300 }
301 }
302
303 pub async fn delete_file_with_link_rewrite_to_stale(
307 &self,
308 path: &str,
309 expected_hash: Option<&str>,
310 message: &str,
311 ) -> Result<MoveWithLinksResult> {
312 match self {
313 Self::Legacy { .. } => Err(Error::config_error(
314 "Atomic delete + wikilink wrap-as-stale requires write_backend=git. The legacy backend has no multi-file atomic primitive; use force=true on the legacy delete (rename-only — links will dangle) or switch to git.",
315 )),
316 Self::Git(g) => {
317 g.delete_file_with_link_rewrite_to_stale(path, expected_hash, message)
318 .await
319 }
320 }
321 }
322
323 pub async fn move_file_with_link_updates(
328 &self,
329 from: &str,
330 to: &str,
331 expected_hash: Option<&str>,
332 message: &str,
333 ) -> Result<MoveWithLinksResult> {
334 match self {
335 Self::Legacy { .. } => Err(Error::config_error(
336 "Atomic move + wikilink update requires write_backend=git. The legacy backend has no multi-file atomic primitive; use the legacy `move_file` flow (rename only; links will dangle) or switch to git.",
337 )),
338 Self::Git(g) => {
339 g.move_file_with_link_updates(from, to, expected_hash, message)
340 .await
341 }
342 }
343 }
344
345 pub async fn batch_execute_with_message(
346 &self,
347 operations: Vec<BatchOperation>,
348 message: &str,
349 ) -> Result<BatchResult> {
350 match self {
351 Self::Legacy { batch, .. } => {
352 legacy_batch_refusal(&operations)?;
353 batch.batch_execute(operations).await
355 }
356 Self::Git(g) => g.batch_execute_with_message(operations, message).await,
357 }
358 }
359
360 pub async fn edit_file(
361 &self,
362 path: &str,
363 edits: &str,
364 expected_hash: Option<&str>,
365 dry_run: bool,
366 ) -> Result<EditResult> {
367 match self {
368 Self::Legacy { files, .. } => {
369 files.edit_file(path, edits, expected_hash, dry_run).await
370 }
371 Self::Git(g) => g.edit_file(path, edits, expected_hash, dry_run).await,
372 }
373 }
374
375 pub async fn delete_file(&self, path: &str) -> Result<()> {
376 match self {
377 Self::Legacy { files, .. } => files.delete_file(path).await,
378 Self::Git(g) => g.delete_file(path).await,
379 }
380 }
381
382 pub async fn delete_file_with_hash(
383 &self,
384 path: &str,
385 expected_hash: Option<&str>,
386 ) -> Result<()> {
387 match self {
388 Self::Legacy { files, .. } => files.delete_file_with_hash(path, expected_hash).await,
389 Self::Git(g) => g.delete_file_with_hash(path, expected_hash).await,
390 }
391 }
392
393 pub async fn move_file(&self, from: &str, to: &str) -> Result<()> {
394 match self {
395 Self::Legacy { files, .. } => files.move_file(from, to).await,
396 Self::Git(g) => g.move_file(from, to).await,
397 }
398 }
399
400 pub async fn move_file_with_hash(
401 &self,
402 from: &str,
403 to: &str,
404 expected_hash: Option<&str>,
405 ) -> Result<()> {
406 match self {
407 Self::Legacy { files, .. } => files.move_file_with_hash(from, to, expected_hash).await,
408 Self::Git(g) => g.move_file_with_hash(from, to, expected_hash).await,
409 }
410 }
411
412 pub async fn copy_file(&self, from: &str, to: &str) -> Result<()> {
413 match self {
414 Self::Legacy { files, .. } => files.copy_file(from, to).await,
415 Self::Git(g) => g.copy_file(from, to).await,
416 }
417 }
418
419 pub async fn batch_execute(&self, operations: Vec<BatchOperation>) -> Result<BatchResult> {
420 match self {
421 Self::Legacy { batch, .. } => {
422 legacy_batch_refusal(&operations)?;
427 batch.batch_execute(operations).await
428 }
429 Self::Git(g) => g.batch_execute(operations).await,
430 }
431 }
432}
433
434fn first_git_only_op(operations: &[BatchOperation]) -> Option<(usize, &'static str)> {
439 operations
440 .iter()
441 .enumerate()
442 .find_map(|(i, op)| op.git_only_kind().map(|kind| (i, kind)))
443}
444
445fn legacy_batch_refusal(operations: &[BatchOperation]) -> Result<()> {
454 if let Some((idx, kind)) = first_git_only_op(operations) {
455 return Err(Error::config_error(format!(
456 "BatchOperation at index {idx} ({kind}) requires write_backend=git; the legacy batch executor has no equivalent. Switch the vault to the git backend to use it."
457 )));
458 }
459 Ok(())
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469 use tempfile::TempDir;
470 use turbovault_core::config::{ServerConfig, VaultConfig};
471 use turbovault_vault::VaultManager;
472
473 fn test_server_config(vault_dir: &std::path::Path, name: &str) -> ServerConfig {
474 let mut cfg = ServerConfig::new();
475 cfg.vaults
476 .push(VaultConfig::builder(name, vault_dir).build().unwrap());
477 cfg
478 }
479
480 async fn legacy_tools(tmp: &TempDir) -> WriteTools {
481 let manager = Arc::new(VaultManager::new(test_server_config(tmp.path(), "l")).unwrap());
482 WriteTools::legacy(manager)
483 }
484
485 async fn git_tools(tmp: &TempDir) -> WriteTools {
486 let mut opts = git2::RepositoryInitOptions::new();
487 opts.initial_head("main");
488 git2::Repository::init_opts(tmp.path(), &opts).unwrap();
489 let manager = Arc::new(VaultManager::new(test_server_config(tmp.path(), "g")).unwrap());
490 let locks = Arc::new(CommitLocks::new());
491 WriteTools::git(manager, tmp.path().to_path_buf(), locks)
492 }
493
494 #[tokio::test]
495 async fn legacy_dispatch_writes_and_reads_back() {
496 let tmp = TempDir::new().unwrap();
497 let tools = legacy_tools(&tmp).await;
498 tools.write_file("a.md", "alpha").await.unwrap();
499 assert_eq!(tools.read_file("a.md").await.unwrap(), "alpha");
500 }
501
502 #[tokio::test]
503 async fn git_dispatch_writes_and_reads_back() {
504 let tmp = TempDir::new().unwrap();
505 let tools = git_tools(&tmp).await;
506 tools.write_file("a.md", "alpha").await.unwrap();
507 assert_eq!(tools.read_file("a.md").await.unwrap(), "alpha");
508 let repo = git2::Repository::open(tmp.path()).unwrap();
510 assert!(repo.head().is_ok(), "HEAD now exists");
511 assert!(matches!(tools, WriteTools::Git(_)));
512 }
513
514 #[tokio::test]
517 async fn git_create_file_aborts_on_existing_path() {
518 let tmp = TempDir::new().unwrap();
519 let tools = git_tools(&tmp).await;
520 tools.write_file("dup.md", "v1").await.unwrap();
521 let err = tools.create_file("dup.md", "v2").await.unwrap_err();
522 assert!(
523 matches!(err, Error::ConcurrencyError { .. }),
524 "got: {err:?}"
525 );
526 assert_eq!(tools.read_file("dup.md").await.unwrap(), "v1");
527 }
528
529 #[tokio::test]
532 async fn legacy_batch_honors_per_op_precondition_preflight() {
533 let tmp = TempDir::new().unwrap();
534 let tools = legacy_tools(&tmp).await;
535 let ops = vec![BatchOperation::WriteNote {
536 path: "a.md".into(),
537 content: "v".into(),
538 expected_hash: Some("0123456789abcdef0123456789abcdef01234567".into()),
539 }];
540 let result = tools.batch_execute(ops).await.unwrap();
541 assert!(!result.success);
542 assert!(!tmp.path().join("a.md").exists());
543 }
544
545 #[tokio::test]
550 async fn legacy_batch_refuses_git_only_edit_note() {
551 let tmp = TempDir::new().unwrap();
552 let tools = legacy_tools(&tmp).await;
553 let ops = vec![
554 BatchOperation::WriteNote {
555 path: "kept.md".into(),
556 content: "v".into(),
557 expected_hash: None,
558 },
559 BatchOperation::EditNote {
560 path: "kept.md".into(),
561 edits: "<<<<<<< SEARCH\nv\n=======\nw\n>>>>>>> REPLACE".into(),
562 expected_hash: None,
563 },
564 ];
565 let err = tools.batch_execute(ops).await.unwrap_err();
566 let msg = err.to_string();
567 assert!(
568 msg.contains("write_backend=git") && msg.contains("EditNote"),
569 "expected git-only refusal, got: {msg}"
570 );
571 assert!(
573 !tmp.path().join("kept.md").exists(),
574 "no op applied on a refused legacy batch"
575 );
576 }
577
578 #[tokio::test]
581 async fn legacy_batch_passes_through_when_no_preconditions() {
582 let tmp = TempDir::new().unwrap();
583 let tools = legacy_tools(&tmp).await;
584 let ops = vec![BatchOperation::WriteNote {
585 path: "a.md".into(),
586 content: "v".into(),
587 expected_hash: None,
588 }];
589 let res = tools.batch_execute(ops).await.unwrap();
590 assert!(res.success);
591 }
592
593 #[tokio::test]
597 async fn legacy_create_file_is_blind_fallback() {
598 let tmp = TempDir::new().unwrap();
599 let tools = legacy_tools(&tmp).await;
600 tools.write_file("dup.md", "v1").await.unwrap();
601 tools.create_file("dup.md", "v2").await.unwrap();
603 assert_eq!(tools.read_file("dup.md").await.unwrap(), "v2");
604 }
605
606 #[tokio::test]
607 async fn dispatch_observably_different_for_batch_atomicity() {
608 let make_ops = || {
612 vec![
613 BatchOperation::WriteNote {
614 path: "first.md".into(),
615 content: "F".into(),
616 expected_hash: None,
617 },
618 BatchOperation::MoveNote {
619 from: "missing.md".into(),
620 to: "anywhere.md".into(),
621 expected_hash: None,
622 update_backlinks: None,
623 },
624 BatchOperation::WriteNote {
625 path: "third.md".into(),
626 content: "T".into(),
627 expected_hash: None,
628 },
629 ]
630 };
631
632 let l_tmp = TempDir::new().unwrap();
633 let l = legacy_tools(&l_tmp).await;
634 let l_res = l.batch_execute(make_ops()).await.unwrap();
635 assert!(!l_res.success);
636 assert!(
639 l_tmp.path().join("first.md").exists(),
640 "legacy leaves partial state behind"
641 );
642
643 let g_tmp = TempDir::new().unwrap();
644 let g = git_tools(&g_tmp).await;
645 let g_res = g.batch_execute(make_ops()).await.unwrap();
646 assert!(!g_res.success);
647 assert!(
648 !g_tmp.path().join("first.md").exists(),
649 "git substrate aborts atomically — no partial state"
650 );
651 assert!(!g_tmp.path().join("third.md").exists());
652 }
653}