Skip to main content

co_core_file/
lib.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2// Copyright (C) 2026 1io BRANDGUARDIAN GmbH
3
4use anyhow::anyhow;
5use cid::Cid;
6use co_api::{
7	co, tags, AbsolutePath, AbsolutePathOwned, BlockStorageExt, CoMap, CoSet, CoreBlockStorage, Date, Did, Link,
8	OptionLink, PathExt, PathOwned, Reducer, ReducerAction, Tags,
9};
10use futures::{FutureExt, TryStreamExt};
11use std::collections::{BTreeMap, BTreeSet, VecDeque};
12
13#[co(state)]
14pub struct File {
15	pub nodes: CoMap<AbsolutePathOwned, CoSet<Node>>,
16}
17
18#[co]
19pub enum Node {
20	Folder(FolderNode),
21	File(FileNode),
22	Link(LinkNode),
23}
24
25#[co]
26pub struct FileNode {
27	pub name: String,
28	pub create_time: Date,
29	pub modify_time: Date,
30	pub size: u64,
31	pub mode: u32,
32	pub tags: Tags,
33	// #[external]
34	pub contents: Cid,
35	pub owner: Did,
36}
37
38#[co]
39pub struct FolderNode {
40	pub name: String,
41	pub create_time: Date,
42	pub modify_time: Date,
43	pub tags: Tags,
44	pub owner: Did,
45	pub mode: u32,
46}
47
48#[co]
49pub struct LinkNode {
50	pub name: String,
51	pub tags: Tags,
52	pub contents: PathOwned,
53}
54
55#[co]
56pub enum FileAction {
57	/// Create a node.
58	/// Ignored if a node with the same name already exists at path.
59	Create {
60		/// The parent to create the node in.
61		path: AbsolutePathOwned,
62		/// The node to create.
63		node: Node,
64		/// Whether to create parents recursively.
65		recursive: bool,
66	},
67
68	/// Remove a node.
69	/// If a node has children and recusive is set to false nothing will happen.
70	Remove { path: AbsolutePathOwned, recursive: bool },
71
72	/// Modify a node.
73	Modify { path: AbsolutePathOwned, modifications: Vec<FileModification> },
74}
75
76#[co]
77pub enum FileModification {
78	/// Rename node to.
79	Rename(String),
80
81	/// Move node into path (as children).
82	Move(AbsolutePathOwned),
83
84	/// Set create time.
85	SetCreateTime(Date),
86
87	/// Set modify time.
88	SetModifyTime(Date),
89
90	/// Set mode.
91	SetMode(u32),
92
93	/// Set owner.
94	SetOwner(Did),
95
96	/// Insert tags.
97	TagsInsert(Tags),
98
99	/// Remove tags.
100	TagsRemove(Tags),
101
102	/// Set file contents.
103	/// Only applicable to [`Node::File`].
104	SetContents(Cid, u64),
105
106	/// Set link target.
107	/// Only applicable to [`Node::Link`].
108	SetLink(PathOwned),
109}
110
111impl Reducer<FileAction> for File {
112	async fn reduce(
113		state: OptionLink<Self>,
114		event: Link<ReducerAction<FileAction>>,
115		storage: &CoreBlockStorage,
116	) -> Result<Link<Self>, anyhow::Error> {
117		let action = storage.get_value(&event).await?;
118		let mut result = storage.get_value_or_default(&state).await?;
119		match &action.payload {
120			FileAction::Create { path, node, recursive } => {
121				reduce_create(storage, &mut result, path, node, &action.from, action.time, *recursive)
122					.boxed()
123					.await?;
124			},
125			FileAction::Remove { path, recursive } => {
126				reduce_remove(storage, &mut result, path, *recursive).boxed().await?;
127			},
128			FileAction::Modify { path, modifications } => {
129				reduce_modify(storage, &mut result, path, modifications).boxed().await?;
130			},
131		}
132		Ok(storage.set_value(&result).await?)
133	}
134}
135
136impl Node {
137	pub fn name(&self) -> &str {
138		match self {
139			Node::Folder(node) => &node.name,
140			Node::File(node) => &node.name,
141			Node::Link(node) => &node.name,
142		}
143	}
144
145	pub fn is_dir(&self) -> bool {
146		matches!(self, Node::Folder(_))
147	}
148
149	pub fn is_file(&self) -> bool {
150		matches!(self, Node::File(_))
151	}
152
153	pub fn is_link(&self) -> bool {
154		matches!(self, Node::Link(_))
155	}
156
157	pub fn modify(
158		&mut self,
159		context: &mut FileModificationContext,
160		modification: &FileModification,
161	) -> anyhow::Result<()> {
162		match self {
163			Node::Folder(folder_node) => folder_node.modify(context, modification),
164			Node::File(file_node) => file_node.modify(context, modification),
165			Node::Link(link_node) => link_node.modify(context, modification),
166		}
167	}
168}
169
170impl FileNode {
171	pub fn modify(
172		&mut self,
173		_context: &mut FileModificationContext,
174		modification: &FileModification,
175	) -> anyhow::Result<()> {
176		match modification {
177			FileModification::Rename(name) => {
178				self.name = name.to_owned();
179			},
180			FileModification::Move(_) => {
181				// nothing todo (files can not have children)
182			},
183			FileModification::SetCreateTime(time) => {
184				self.create_time = *time;
185			},
186			FileModification::SetModifyTime(time) => {
187				self.modify_time = *time;
188			},
189			FileModification::SetMode(mode) => {
190				self.mode = *mode;
191			},
192			FileModification::SetOwner(owner) => {
193				self.owner = owner.to_owned();
194			},
195			FileModification::TagsInsert(tags) => {
196				self.tags.append(&mut tags.clone());
197			},
198			FileModification::TagsRemove(tags) => {
199				self.tags.clear(Some(tags));
200			},
201			FileModification::SetContents(cid, size) => {
202				self.contents = *cid;
203				self.size = *size;
204			},
205			modification => return Err(anyhow!("Unsupported modification: {:?}", modification)),
206		}
207		Ok(())
208	}
209}
210
211impl FolderNode {
212	pub fn modify(
213		&mut self,
214		context: &mut FileModificationContext,
215		modification: &FileModification,
216	) -> anyhow::Result<()> {
217		match modification {
218			FileModification::Rename(name) => {
219				if &self.name != name {
220					context.reparent(
221						context.path(),
222						context
223							.path()
224							.parent()
225							.ok_or(anyhow!("No parent: {}", context.path()))?
226							.join_path(name)?,
227					)?;
228				}
229				self.name = name.to_owned();
230			},
231			FileModification::Move(_to) => {
232				// nothing todo (handles in `reduce_modify`)
233			},
234			FileModification::SetCreateTime(time) => {
235				self.create_time = *time;
236			},
237			FileModification::SetModifyTime(time) => {
238				self.modify_time = *time;
239			},
240			FileModification::SetMode(mode) => {
241				self.mode = *mode;
242			},
243			FileModification::SetOwner(owner) => {
244				self.owner = owner.to_owned();
245			},
246			FileModification::TagsInsert(tags) => {
247				self.tags.append(&mut tags.clone());
248			},
249			FileModification::TagsRemove(tags) => {
250				self.tags.clear(Some(tags));
251			},
252			modification => return Err(anyhow!("Unsupported modification: {:?}", modification)),
253		}
254		Ok(())
255	}
256}
257
258impl LinkNode {
259	pub fn modify(
260		&mut self,
261		_context: &mut FileModificationContext,
262		modification: &FileModification,
263	) -> anyhow::Result<()> {
264		match modification {
265			FileModification::Rename(name) => {
266				self.name = name.to_owned();
267			},
268			FileModification::Move(_to) => {
269				// TODO: change the symlink target?
270				// nothing todo as links can not have children
271			},
272			// TODO: should symlink have own metadata? on posix they have:
273			// A symlink has its own metadata, including:
274			// Mode (permissions, typically lrwxrwxrwx)
275			// (Possibly) a creation time, if supported by the filesystem
276			// A modification time, reflecting changes to the symlink itself
277			// The symlink's metadata is separate from that of the target file it points to.
278			// FileModification::SetCreateTime(time) => {
279			// 	self.create_time = *time;
280			// },
281			// FileModification::SetModifyTime(time) => {
282			// 	self.modify_time = *time;
283			// },
284			// FileModification::SetMode(mode) => {
285			// 	self.mode = *mode;
286			// },
287			// FileModification::SetOwner(owner) => {
288			// 	self.owner = owner.to_owned();
289			// },
290			FileModification::TagsInsert(tags) => {
291				self.tags.append(&mut tags.clone());
292			},
293			FileModification::TagsRemove(tags) => {
294				self.tags.clear(Some(tags));
295			},
296			FileModification::SetLink(path) => {
297				self.contents = path.to_owned();
298			},
299			modification => return Err(anyhow!("Unsupported modification: {:?}", modification)),
300		}
301		Ok(())
302	}
303}
304
305async fn reduce_create(
306	storage: &CoreBlockStorage,
307	state: &mut File,
308	path: &AbsolutePath,
309	node: &Node,
310	from: &Did,
311	time: Date,
312	recursive: bool,
313) -> Result<(), anyhow::Error> {
314	let path = path.normalize()?;
315
316	// test if node exists
317	let node_path = path.join_path(node.name())?;
318	if get_node(storage, &state.nodes, &node_path, true).await?.is_some() {
319		return Ok(());
320	}
321
322	// implicitly create empty root on first create
323	let root: AbsolutePathOwned = AbsolutePath::new_unchecked("/").to_owned();
324	if !state.nodes.contains(storage, &root).await? {
325		state.nodes.insert(storage, root, Default::default()).await?;
326	}
327
328	// recursive?
329	if recursive {
330		for parent in path.paths() {
331			let parent_owned = parent.to_owned();
332			if !state.nodes.contains(storage, &parent_owned).await? {
333				create_folder(storage, &mut state.nodes, parent, from, time).await?;
334			}
335		}
336	}
337
338	// insert if name not exists already
339	create_node(storage, &mut state.nodes, &path, node.clone()).await
340}
341
342async fn reduce_remove(
343	storage: &CoreBlockStorage,
344	state: &mut File,
345	path: &AbsolutePath,
346	recursive: bool,
347) -> Result<(), anyhow::Error> {
348	let path = path.normalize()?;
349	let (parent_path, name) = path.parent_and_file_name_result()?;
350
351	// children
352	let mut stack = VecDeque::new();
353	stack.push_back(path.clone());
354	while let Some(current) = stack.pop_front() {
355		let children = state.nodes.get(storage, &current).await?;
356		if let Some(children) = children {
357			// do nothing if we still have children and not delete them
358			if !recursive {
359				return Ok(());
360			}
361
362			// queue children
363			let child_nodes: Vec<Node> = children.stream(storage).try_collect().await?;
364			for child in &child_nodes {
365				stack.push_back(current.join_path(child.name())?);
366			}
367
368			// remove
369			state.nodes.remove(storage, current).await?;
370		}
371	}
372
373	// remove node from parent
374	remove_node_by_name(storage, &mut state.nodes, parent_path, name).await?;
375
376	Ok(())
377}
378
379async fn reduce_modify(
380	storage: &CoreBlockStorage,
381	state: &mut File,
382	path: &AbsolutePath,
383	modifications: &[FileModification],
384) -> Result<(), anyhow::Error> {
385	let path = path.normalize()?;
386	let (parent_path, name) = path.parent_and_file_name_result()?;
387	let parent_path = parent_path.to_owned();
388	let mut file_modification_context = FileModificationContext::new(path.clone());
389
390	// move node
391	for to_parent in modifications.iter().filter_map(|item| match item {
392		FileModification::Move(path) => Some(path),
393		_ => None,
394	}) {
395		// validate: check `to_parent` exists
396		let validated_to_parent = if to_parent == "/" {
397			to_parent.to_owned()
398		} else if let Some((to_parent, node)) = get_node(storage, &state.nodes, to_parent, true).await? {
399			if !node.is_dir() {
400				return Err(anyhow!("Can only move into folders: {}", to_parent));
401			}
402			to_parent
403		} else {
404			return Err(anyhow!("Not found: {}", to_parent));
405		};
406
407		// validate: check node `name` doesnt exist in `to_parent`
408		let to_path = validated_to_parent.join_path(name)?;
409		if get_node(storage, &state.nodes, &to_path, true).await?.is_some() {
410			return Err(anyhow!("Node exists: {}", to_path));
411		}
412
413		// remove
414		let removed = remove_node_by_name(storage, &mut state.nodes, &parent_path, name).await?;
415
416		// insert
417		for node in removed {
418			create_node(storage, &mut state.nodes, &validated_to_parent, node).await?;
419		}
420
421		// reparent
422		file_modification_context.reparent(path.clone(), to_path)?;
423	}
424
425	// update node
426	let modifications: Vec<&FileModification> = modifications
427		.iter()
428		.filter_map(|item| match item {
429			FileModification::Move(_) => None,
430			modification => Some(modification),
431		})
432		.collect();
433	if !modifications.is_empty() {
434		// get the node set for the parent
435		if let Some(mut node_set) = state.nodes.get(storage, &parent_path).await? {
436			// check for rename conflicts
437			for modification in modifications.iter() {
438				if let FileModification::Rename(new_name) = modification {
439					let has_conflict = node_set
440						.stream(storage)
441						.try_any(|node| std::future::ready(node.name() == new_name))
442						.await?;
443					if has_conflict {
444						return Err(anyhow!("File exists: {}", parent_path.join_path(new_name)?));
445					}
446				}
447			}
448
449			// find and update the node
450			let nodes: Vec<Node> = node_set.stream(storage).try_collect().await?;
451			let mut updated_nodes = Vec::with_capacity(nodes.len());
452			for mut node in nodes {
453				if node.name() == name {
454					for modification in modifications.iter() {
455						node.modify(&mut file_modification_context, modification)?;
456					}
457				}
458				updated_nodes.push(node);
459			}
460			node_set = CoSet::from_iter(storage, updated_nodes).await?;
461			state.nodes.insert(storage, parent_path.clone(), node_set).await?;
462		}
463	}
464
465	// reparent children nodes
466	for (from, to) in file_modification_context.reparent.iter() {
467		reparent(storage, &mut state.nodes, from, to).await?;
468	}
469
470	Ok(())
471}
472
473async fn reparent(
474	storage: &CoreBlockStorage,
475	nodes: &mut CoMap<AbsolutePathOwned, CoSet<Node>>,
476	from: &AbsolutePath,
477	to: &AbsolutePath,
478) -> Result<(), anyhow::Error> {
479	let from_owned = from.to_owned();
480	if let Some(items) = nodes.remove(storage, from_owned).await? {
481		// children
482		let child_nodes: Vec<Node> = items.stream(storage).try_collect().await?;
483		for child in &child_nodes {
484			if child.is_dir() {
485				Box::pin(reparent(storage, nodes, &from.join_path(child.name())?, &to.join_path(child.name())?))
486					.await?;
487			}
488		}
489
490		// self
491		let to_owned = to.to_owned();
492		if nodes.contains(storage, &to_owned).await? {
493			return Err(anyhow!("Path exists: {}", to));
494		}
495		nodes.insert(storage, to_owned, items).await?;
496	}
497	Ok(())
498}
499
500#[derive(Debug)]
501pub struct FileModificationContext {
502	/// Current node path.
503	path: AbsolutePathOwned,
504
505	/// Reparent from -> to.
506	reparent: BTreeMap<AbsolutePathOwned, AbsolutePathOwned>,
507}
508impl FileModificationContext {
509	pub fn new(path: AbsolutePathOwned) -> Self {
510		Self { path, reparent: Default::default() }
511	}
512
513	pub fn path(&self) -> AbsolutePathOwned {
514		self.path.clone()
515	}
516
517	pub fn reparent(&mut self, from: AbsolutePathOwned, to: AbsolutePathOwned) -> Result<(), anyhow::Error> {
518		let from = from.normalize()?;
519		let to = to.normalize()?;
520		if from != to {
521			self.reparent.insert(from, to);
522		}
523		Ok(())
524	}
525}
526
527/// Returns the node and its absolute path (without links if resolve_link is true).
528async fn get_node(
529	storage: &CoreBlockStorage,
530	nodes: &CoMap<AbsolutePathOwned, CoSet<Node>>,
531	path: &AbsolutePath,
532	resolve_link: bool,
533) -> Result<Option<(AbsolutePathOwned, Node)>, anyhow::Error> {
534	let (parent_path, name) = path.parent_and_file_name_result()?;
535	let parent_owned = parent_path.to_owned();
536	let Some(node_set) = nodes.get(storage, &parent_owned).await? else {
537		return Ok(None);
538	};
539
540	let all_nodes: Vec<Node> = node_set.stream(storage).try_collect().await?;
541	let node = all_nodes.into_iter().find(|node| node.name() == name);
542
543	// resolve_link
544	if let Some(node) = &node {
545		if resolve_link {
546			if let Node::Link(link) = node {
547				let target = parent_path.join(&link.contents)?;
548				return Box::pin(get_node(storage, nodes, &target, resolve_link)).await;
549			}
550		}
551	}
552
553	Ok(node.map(|node| (path.to_owned(), node)))
554}
555
556async fn create_node(
557	storage: &CoreBlockStorage,
558	nodes: &mut CoMap<AbsolutePathOwned, CoSet<Node>>,
559	parent_path: &AbsolutePath,
560	node: Node,
561) -> Result<(), anyhow::Error> {
562	// validate parent exists
563	let validated_parent_path = match parent_path.as_str() {
564		// root always exists
565		"/" => parent_path.to_owned(),
566		// check if node exists
567		_ => {
568			get_node(storage, nodes, parent_path, true)
569				.await?
570				.ok_or(anyhow!("No such directory: {}", parent_path))?
571				.0
572		},
573	};
574
575	// get or create node set
576	let mut node_set = nodes.get(storage, &validated_parent_path).await?.unwrap_or_default();
577
578	// insert node if name not exists yet
579	let all_nodes: Vec<Node> = node_set.stream(storage).try_collect().await?;
580	let name_exists = all_nodes.iter().any(|existing| existing.name() == node.name());
581	if !name_exists {
582		node_set.insert(storage, node).await?;
583		nodes.insert(storage, validated_parent_path, node_set).await?;
584	}
585
586	Ok(())
587}
588
589/// Remove node from set.
590async fn remove_node_by_name(
591	storage: &CoreBlockStorage,
592	nodes: &mut CoMap<AbsolutePathOwned, CoSet<Node>>,
593	parent_path: &AbsolutePath,
594	name: &str,
595) -> Result<BTreeSet<Node>, anyhow::Error> {
596	let parent_owned = parent_path.to_owned();
597	let node_set = nodes.get(storage, &parent_owned).await?.unwrap_or_default();
598
599	let all_nodes: Vec<Node> = node_set.stream(storage).try_collect().await?;
600	let mut kept = Vec::new();
601	let mut removed = BTreeSet::new();
602	for node in all_nodes {
603		if node.name() == name {
604			removed.insert(node);
605		} else {
606			kept.push(node);
607		}
608	}
609
610	// store
611	if kept.is_empty() && parent_path != "/" {
612		nodes.remove(storage, parent_owned).await?;
613	} else {
614		let new_set = CoSet::from_iter(storage, kept).await?;
615		nodes.insert(storage, parent_owned, new_set).await?;
616	}
617
618	Ok(removed)
619}
620
621async fn create_folder(
622	storage: &CoreBlockStorage,
623	nodes: &mut CoMap<AbsolutePathOwned, CoSet<Node>>,
624	path: &AbsolutePath,
625	from: &Did,
626	time: Date,
627) -> Result<(), anyhow::Error> {
628	let (parent_path, name) = path.parent_and_file_name_result()?;
629	let node = Node::Folder(FolderNode {
630		name: name.to_owned(),
631		create_time: time,
632		modify_time: time,
633		tags: tags!(),
634		owner: from.to_owned(),
635		mode: 0o665,
636	});
637	create_node(storage, nodes, parent_path, node).await
638}
639
640#[cfg(test)]
641mod tests {
642	use crate::{File, FileAction, FileModification, FileNode, Node};
643	use co_api::{
644		AbsolutePath, AbsolutePathOwned, BlockSerializer, BlockStorage, BlockStorageExt, CoreBlockStorage, Link,
645		OptionLink, Reducer, ReducerAction,
646	};
647	use co_storage::MemoryBlockStorage;
648	use futures::TryStreamExt;
649
650	fn new_storage() -> MemoryBlockStorage {
651		MemoryBlockStorage::default()
652	}
653
654	fn core_storage(storage: &MemoryBlockStorage) -> CoreBlockStorage {
655		CoreBlockStorage::new(storage.clone(), false)
656	}
657
658	async fn create_test_file_state() -> (MemoryBlockStorage, Link<File>) {
659		let storage = new_storage();
660
661		// create
662		let block = BlockSerializer::default().serialize(&"hello world").unwrap();
663		let contents = *block.cid();
664		storage.set(block).await.unwrap();
665		let node = Node::File(FileNode {
666			contents,
667			create_time: 123,
668			modify_time: 123,
669			mode: 0o655,
670			name: "test.txt".to_owned(),
671			owner: "did:local:test".to_owned(),
672			size: 11,
673			tags: Default::default(),
674		});
675		let action = ReducerAction {
676			from: "did:local:test".to_owned(),
677			core: "file".to_owned(),
678			time: 123,
679			payload: FileAction::Create { path: "/hello/world".try_into().unwrap(), node, recursive: true },
680		};
681		let action_link: Link<ReducerAction<FileAction>> = storage.set_value(&action).await.unwrap();
682		let state_link: OptionLink<File> = OptionLink::none();
683		let cs = core_storage(&storage);
684		let state_link = File::reduce(state_link, action_link, &cs).await.unwrap();
685		let state: File = storage.get_value(&state_link).await.unwrap();
686
687		// verify
688		let paths = collect_paths(&storage, &state).await;
689		assert_eq!(paths.len(), 3); // "/", "/hello", "/hello/world"
690		assert_eq!(nodes_at(&storage, &state, "/").await.len(), 1); // "hello"
691		assert_eq!(nodes_at(&storage, &state, "/hello").await.len(), 1); // "world"
692		assert_eq!(nodes_at(&storage, &state, "/hello/world").await.len(), 1); // "test.txt"
693
694		(storage, state_link)
695	}
696
697	async fn collect_paths(storage: &MemoryBlockStorage, state: &File) -> Vec<AbsolutePathOwned> {
698		state
699			.nodes
700			.stream(storage)
701			.map_ok(|(key, _): (AbsolutePathOwned, _)| key)
702			.try_collect::<Vec<AbsolutePathOwned>>()
703			.await
704			.unwrap()
705	}
706
707	async fn nodes_at(storage: &MemoryBlockStorage, state: &File, path: &str) -> Vec<Node> {
708		let path_owned = AbsolutePath::new_unchecked(path).to_owned();
709		match state.nodes.get(storage, &path_owned).await.unwrap() {
710			Some(set) => set.stream(storage).try_collect().await.unwrap(),
711			None => vec![],
712		}
713	}
714
715	async fn names(storage: &MemoryBlockStorage, state: &File, path: &str) -> Vec<String> {
716		nodes_at(storage, state, path)
717			.await
718			.iter()
719			.map(|node| node.name().to_owned())
720			.collect()
721	}
722
723	async fn reduce_action(
724		storage: &MemoryBlockStorage,
725		state_link: Link<File>,
726		action: ReducerAction<FileAction>,
727	) -> (File, Link<File>) {
728		let action_link: Link<ReducerAction<FileAction>> = storage.set_value(&action).await.unwrap();
729		let cs = core_storage(storage);
730		let next_link = File::reduce(state_link.into(), action_link, &cs).await.unwrap();
731		let state: File = storage.get_value(&next_link).await.unwrap();
732		(state, next_link)
733	}
734
735	#[tokio::test]
736	async fn test_create() {
737		let (_storage, _state_link) = create_test_file_state().await;
738	}
739
740	#[tokio::test]
741	async fn test_delete_recursive() {
742		let (storage, state_link) = create_test_file_state().await;
743
744		let action = ReducerAction {
745			from: "did:local:test".to_owned(),
746			core: "file".to_owned(),
747			time: 456,
748			payload: FileAction::Remove { path: "/hello".try_into().unwrap(), recursive: true },
749		};
750		let (state, _) = reduce_action(&storage, state_link, action).await;
751		let paths = collect_paths(&storage, &state).await;
752		assert_eq!(paths.len(), 1); // "/"
753		assert_eq!(nodes_at(&storage, &state, "/").await.len(), 0);
754	}
755
756	#[tokio::test]
757	async fn test_modify_rename() {
758		let (storage, state_link) = create_test_file_state().await;
759
760		let action = ReducerAction {
761			from: "did:local:test".to_owned(),
762			core: "file".to_owned(),
763			time: 456,
764			payload: FileAction::Modify {
765				path: "/hello/world/test.txt".try_into().unwrap(),
766				modifications: vec![FileModification::Rename("welcome.txt".to_owned())],
767			},
768		};
769		let (state, _) = reduce_action(&storage, state_link, action).await;
770		let files = nodes_at(&storage, &state, "/hello/world").await;
771		assert_eq!(files.len(), 1);
772		assert_eq!(files.first().unwrap().name(), "welcome.txt");
773	}
774
775	#[tokio::test]
776	async fn test_modify_rename_with_children() {
777		let (storage, state_link) = create_test_file_state().await;
778
779		let action = ReducerAction {
780			from: "did:local:test".to_owned(),
781			core: "file".to_owned(),
782			time: 456,
783			payload: FileAction::Modify {
784				path: "/hello".try_into().unwrap(),
785				modifications: vec![FileModification::Rename("test".to_owned())],
786			},
787		};
788		let (state, _) = reduce_action(&storage, state_link, action).await;
789		let mut paths: Vec<String> = collect_paths(&storage, &state).await.iter().map(|p| p.to_string()).collect();
790		paths.sort();
791		assert_eq!(paths, vec!["/", "/test", "/test/world"]);
792		assert_eq!(names(&storage, &state, "/").await, vec!["test"]);
793		assert_eq!(names(&storage, &state, "/test").await, vec!["world"]);
794		assert_eq!(names(&storage, &state, "/test/world").await, vec!["test.txt"]);
795	}
796
797	#[tokio::test]
798	async fn test_modify_move() {
799		let (storage, state_link) = create_test_file_state().await;
800
801		let action = ReducerAction {
802			from: "did:local:test".to_owned(),
803			core: "file".to_owned(),
804			time: 456,
805			payload: FileAction::Modify {
806				path: "/hello/world".try_into().unwrap(),
807				modifications: vec![FileModification::Move("/".try_into().unwrap())],
808			},
809		};
810		let (state, _) = reduce_action(&storage, state_link, action).await;
811		let mut paths: Vec<String> = collect_paths(&storage, &state).await.iter().map(|p| p.to_string()).collect();
812		paths.sort();
813		assert_eq!(paths, vec!["/", "/world"]); // "/hello" is empty now
814		let mut root_names = names(&storage, &state, "/").await;
815		root_names.sort();
816		assert_eq!(root_names, vec!["hello", "world"]);
817		assert!(names(&storage, &state, "/hello").await.is_empty());
818		assert_eq!(names(&storage, &state, "/world").await, vec!["test.txt"]);
819	}
820
821	#[tokio::test]
822	async fn test_modify_move_file() {
823		let (storage, state_link) = create_test_file_state().await;
824
825		let action = ReducerAction {
826			from: "did:local:test".to_owned(),
827			core: "file".to_owned(),
828			time: 456,
829			payload: FileAction::Modify {
830				path: "/hello/world/test.txt".try_into().unwrap(),
831				modifications: vec![FileModification::Move("/hello".try_into().unwrap())],
832			},
833		};
834		let (state, _) = reduce_action(&storage, state_link, action).await;
835		let mut paths: Vec<String> = collect_paths(&storage, &state).await.iter().map(|p| p.to_string()).collect();
836		paths.sort();
837		assert_eq!(paths, vec!["/", "/hello"]); // "/world" is empty now
838		assert_eq!(names(&storage, &state, "/").await, vec!["hello"]);
839		let mut hello_names = names(&storage, &state, "/hello").await;
840		hello_names.sort();
841		assert_eq!(hello_names, vec!["test.txt", "world"]);
842		assert!(names(&storage, &state, "/hello/world").await.is_empty());
843	}
844}