From 12a13272d844068d867f16e594dd171b91ce7359 Mon Sep 17 00:00:00 2001 From: xhtmlboi Date: Tue, 10 Feb 2026 17:27:01 +0100 Subject: [PATCH 01/12] Add `mkdir` in an Unix fashion --- lib/tree.ml | 28 ++++++++++++++++ lib/tree.mli | 18 ++++++++++ test/dune | 3 +- test/mkdir_test.ml | 82 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 test/mkdir_test.ml diff --git a/lib/tree.ml b/lib/tree.ml index 9beb709..59f5cc0 100644 --- a/lib/tree.ml +++ b/lib/tree.ml @@ -292,6 +292,9 @@ module Dummy = struct type content = string type nonrec item = (content, metadata) item type nonrec t = (content, metadata) t + type error = Mkdir of Path.t * string + + exception Dummy_tree of error let dummy_clock x _ = x @@ -310,6 +313,31 @@ module Dummy = struct dir ~metadata:{ mtime = clock name } ~name children ;; + let error_s path prim err reason = + prim ^ ": " ^ err ^ " '" ^ Path.to_string path ^ "': " ^ reason + ;; + + let error_to_string = function + | Mkdir (p, reason) -> error_s p "mkdir" "cannot create directory" reason + ;; + + let raise_error error = raise (Dummy_tree error) + + let mkdir ?(clock = dummy_clock 1.0) ~path fs = + let dname = Path.dirname path in + match fetch ~path:dname fs, fetch ~path fs with + | Some _, None -> + update + ~path + (fun ~previous:_ ~path -> + let bname = Path.basename path in + let item = dir ~clock ~name:bname [] in + Some item) + fs + | _, Some _ -> raise_error (Mkdir (path, "File exists")) + | None, _ -> raise_error (Mkdir (path, "No such file or directory")) + ;; + let mtime item = item |> metadata diff --git a/lib/tree.mli b/lib/tree.mli index eacfbbe..a1698a9 100644 --- a/lib/tree.mli +++ b/lib/tree.mli @@ -188,6 +188,19 @@ module Dummy : sig (** Items of the file system. *) type nonrec t = (content, metadata) t + (** {2 Error handling} + + The API relies on exceptions to describe failures. Each function + that may fail throws the [Dummy_tree] exception. *) + + (** Set of all possible errors. *) + type error + + exception Dummy_tree of error + + (** Render an error as an Unix-like error message. *) + val error_to_string : error -> string + (** {1 Tree construction} *) (** [dummy_clock f] creates a constant clock, always returning @@ -207,4 +220,9 @@ module Dummy : sig (** [mtime item] returns the {i modification time} of the given [item]. *) val mtime : item -> float + + (** [mkdir ?clock ~path] creates the directory referenced by the + given [path] with behaviour similar to the Unix command + [mkdir]. *) + val mkdir : ?clock:(content -> time) -> path:Path.t -> t -> t end diff --git a/test/dune b/test/dune index 36b4afe..d273b1c 100644 --- a/test/dune +++ b/test/dune @@ -9,6 +9,7 @@ path_move_test yocaml_www_resolver tree_core_test - tree_update_test) + tree_update_test + mkdir_test) (preprocess (pps ppx_expect))) diff --git a/test/mkdir_test.ml b/test/mkdir_test.ml new file mode 100644 index 0000000..eca6661 --- /dev/null +++ b/test/mkdir_test.ml @@ -0,0 +1,82 @@ +(* Copyright (c) 2026, Cargocut and the Virtfs developers. + All rights reserved. + + SPDX-License-Identifier: BSD-3-Clause *) + +module T = Tree +module U = T.Dummy + +let mtime path fs = + fs |> T.fetch ~path |> Option.map U.mtime |> Option.value ~default:0.0 +;; + +let fs = + let open U in + mount + ~scope:Path.root + [ dir + ~name:"1-foo" + [ dir ~name:"bar" [ file ~name:"index.md" "Hello World" ] ] + ; dir + ~name:"2-foo" + [ dir + ~name:"bar" + [ dir ~name:"baz" [ file ~name:"index.md" "Hello World 2" ] ] + ] + ; dir ~name:"3-foo" [ dir ~name:"bar" [] ] + ] +;; + +let%expect_test "mkdir when nested path does not exists" = + let clock _ = 2.0 in + let path = Path.abs [ "1-foo"; "bar"; "baz"; "a-directory" ] in + let f () = + try fs |> U.mkdir ~clock ~path |> T.tree |> print_endline with + | U.Dummy_tree err -> err |> U.error_to_string |> print_endline + in + f (); + [%expect + {| mkdir: cannot create directory '/1-foo/bar/baz/a-directory': No such file or directory |}] +;; + +let%expect_test "mkdir when target exists" = + let clock _ = 2.0 in + let path = Path.abs [ "1-foo"; "bar" ] in + let f () = + try fs |> U.mkdir ~clock ~path |> T.tree |> print_endline with + | U.Dummy_tree err -> err |> U.error_to_string |> print_endline + in + f (); + [%expect {| mkdir: cannot create directory '/1-foo/bar': File exists |}] +;; + +let%expect_test "mkdir" = + let clock _ = 2.0 in + let path = Path.abs [ "1-foo"; "bar"; "storage" ] in + let f () = + try + let fs = fs |> U.mkdir ~clock ~path in + let tm = mtime path fs in + tm |> Float.to_string |> print_endline; + fs |> T.tree |> print_endline + with + | U.Dummy_tree err -> err |> U.error_to_string |> print_endline + in + f (); + [%expect + {| + 2. + + └─/ + └─1-foo/ + └─bar/ + └─storage/ + └─index.md + └─2-foo/ + └─bar/ + └─baz/ + └─index.md + └─3-foo/ + └─bar/ + |}] +;; From 016123d854939c30213b1b43fb126cb3b6c1970b Mon Sep 17 00:00:00 2001 From: xhtmlboi Date: Tue, 10 Feb 2026 17:44:45 +0100 Subject: [PATCH 02/12] Add `mkdir_p` for recursive directory creation --- lib/tree.ml | 14 ++++++++ lib/tree.mli | 5 +++ test/mkdir_test.ml | 81 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+) diff --git a/lib/tree.ml b/lib/tree.ml index 59f5cc0..6c285f4 100644 --- a/lib/tree.ml +++ b/lib/tree.ml @@ -338,6 +338,20 @@ module Dummy = struct | None, _ -> raise_error (Mkdir (path, "No such file or directory")) ;; + let mkdir_p ?(clock = dummy_clock 1.0) ~path fs = + let rec aux path fs = + let file = fetch ~path fs in + match file with + | Some (File _) -> raise_error (Mkdir (path, "File exists")) + | Some (Directory _) -> fs + | None -> + let p = Path.dirname path in + let fs = aux p fs in + mkdir ~clock ~path fs + in + aux path fs + ;; + let mtime item = item |> metadata diff --git a/lib/tree.mli b/lib/tree.mli index a1698a9..4b33c4d 100644 --- a/lib/tree.mli +++ b/lib/tree.mli @@ -225,4 +225,9 @@ module Dummy : sig given [path] with behaviour similar to the Unix command [mkdir]. *) val mkdir : ?clock:(content -> time) -> path:Path.t -> t -> t + + (** [mkdir_p ?clock ~path] creates the directory referenced by the + given [path] with behaviour similar to the Unix command + [mkdir -p]. *) + val mkdir_p : ?clock:(content -> time) -> path:Path.t -> t -> t end diff --git a/test/mkdir_test.ml b/test/mkdir_test.ml index eca6661..afaf307 100644 --- a/test/mkdir_test.ml +++ b/test/mkdir_test.ml @@ -80,3 +80,84 @@ let%expect_test "mkdir" = └─bar/ |}] ;; + +let%expect_test "mkdir_p" = + let clock _ = 5.0 in + let path = Path.abs [ "1-foo"; "bar"; "index.md" ] in + let f () = + try + let fs = fs |> U.mkdir_p ~clock ~path in + let tm = mtime path fs in + tm |> Float.to_string |> print_endline; + fs |> T.tree |> print_endline + with + | U.Dummy_tree err -> err |> U.error_to_string |> print_endline + in + f (); + [%expect + {| mkdir: cannot create directory '/1-foo/bar/index.md': File exists |}] +;; + +let%expect_test "mkdir_p" = + let clock _ = 5.0 in + let path = Path.abs [ "1-foo"; "bar" ] in + let f () = + try + let fs = fs |> U.mkdir_p ~clock ~path in + let tm = mtime path fs in + tm |> Float.to_string |> print_endline; + fs |> T.tree |> print_endline + with + | U.Dummy_tree err -> err |> U.error_to_string |> print_endline + in + f (); + [%expect + {| + 1. + + └─/ + └─1-foo/ + └─bar/ + └─index.md + └─2-foo/ + └─bar/ + └─baz/ + └─index.md + └─3-foo/ + └─bar/ + |}] +;; + +let%expect_test "mkdir_p" = + let clock _ = 5.0 in + let path = Path.abs [ "4-foo"; "bar"; "baz"; "storage" ] in + let f () = + try + let fs = fs |> U.mkdir_p ~clock ~path in + let tm = mtime path fs in + tm |> Float.to_string |> print_endline; + fs |> T.tree |> print_endline + with + | U.Dummy_tree err -> err |> U.error_to_string |> print_endline + in + f (); + [%expect + {| + 5. + + └─/ + └─1-foo/ + └─bar/ + └─index.md + └─2-foo/ + └─bar/ + └─baz/ + └─index.md + └─3-foo/ + └─bar/ + └─4-foo/ + └─bar/ + └─baz/ + └─storage/ + |}] +;; From f3a834ff2a9b98426f9cc8f1c44e504877a3068b Mon Sep 17 00:00:00 2001 From: xhtmlboi Date: Wed, 11 Feb 2026 11:43:16 +0100 Subject: [PATCH 03/12] Rewriting `mtime` (to act on a file system rather than an item) Co-authored-by: mspwn --- lib/tree.ml | 41 +++++++++++++++++++++++++---------------- lib/tree.mli | 5 +++-- test/mkdir_test.ml | 12 ++++-------- 3 files changed, 32 insertions(+), 26 deletions(-) diff --git a/lib/tree.ml b/lib/tree.ml index 6c285f4..e9c4264 100644 --- a/lib/tree.ml +++ b/lib/tree.ml @@ -292,10 +292,21 @@ module Dummy = struct type content = string type nonrec item = (content, metadata) item type nonrec t = (content, metadata) t - type error = Mkdir of Path.t * string + + type error = + | Mkdir of Path.t * string + | Stat of Path.t * string exception Dummy_tree of error + let err_file_exists = "File exists" + let err_no_such_target = "No such file or directory" + + let error_s path prim err reason = + prim ^ ": " ^ err ^ " '" ^ Path.to_string path ^ "': " ^ reason + ;; + + let raise_error error = raise (Dummy_tree error) let dummy_clock x _ = x let mount ?(clock = dummy_clock 1.0) ~scope children = @@ -313,16 +324,11 @@ module Dummy = struct dir ~metadata:{ mtime = clock name } ~name children ;; - let error_s path prim err reason = - prim ^ ": " ^ err ^ " '" ^ Path.to_string path ^ "': " ^ reason - ;; - let error_to_string = function | Mkdir (p, reason) -> error_s p "mkdir" "cannot create directory" reason + | Stat (p, reason) -> error_s p "stat" "cannot statx" reason ;; - let raise_error error = raise (Dummy_tree error) - let mkdir ?(clock = dummy_clock 1.0) ~path fs = let dname = Path.dirname path in match fetch ~path:dname fs, fetch ~path fs with @@ -334,15 +340,15 @@ module Dummy = struct let item = dir ~clock ~name:bname [] in Some item) fs - | _, Some _ -> raise_error (Mkdir (path, "File exists")) - | None, _ -> raise_error (Mkdir (path, "No such file or directory")) + | _, Some _ -> raise_error (Mkdir (path, err_file_exists)) + | None, _ -> raise_error (Mkdir (path, err_no_such_target)) ;; let mkdir_p ?(clock = dummy_clock 1.0) ~path fs = let rec aux path fs = let file = fetch ~path fs in match file with - | Some (File _) -> raise_error (Mkdir (path, "File exists")) + | Some (File _) -> raise_error (Mkdir (path, err_file_exists)) | Some (Directory _) -> fs | None -> let p = Path.dirname path in @@ -352,11 +358,14 @@ module Dummy = struct aux path fs ;; - let mtime item = - item - |> metadata - |> Option.fold - ~none:0.0 (* OKAY: having [0.0] as a default result seems ok. *) - ~some:(fun { mtime } -> mtime) + let mtime ~path fs = + match fetch ~path fs with + | Some item -> + item + |> metadata + |> Option.fold + ~none:0.0 (* OKAY: having [0.0] as a default result seems ok. *) + ~some:(fun { mtime } -> mtime) + | _ -> raise_error (Stat (path, err_no_such_target)) ;; end diff --git a/lib/tree.mli b/lib/tree.mli index 4b33c4d..5405f2b 100644 --- a/lib/tree.mli +++ b/lib/tree.mli @@ -218,8 +218,9 @@ module Dummy : sig parametrized by the [name] of the directory. *) val dir : ?clock:string clock -> name:string -> item list -> item - (** [mtime item] returns the {i modification time} of the given [item]. *) - val mtime : item -> float + (** [mtime ~path fs] returns the {i modification time} of the given + {!type:item} located at the given [path]. *) + val mtime : path:Path.t -> t -> float (** [mkdir ?clock ~path] creates the directory referenced by the given [path] with behaviour similar to the Unix command diff --git a/test/mkdir_test.ml b/test/mkdir_test.ml index afaf307..a70168e 100644 --- a/test/mkdir_test.ml +++ b/test/mkdir_test.ml @@ -6,10 +6,6 @@ module T = Tree module U = T.Dummy -let mtime path fs = - fs |> T.fetch ~path |> Option.map U.mtime |> Option.value ~default:0.0 -;; - let fs = let open U in mount @@ -56,7 +52,7 @@ let%expect_test "mkdir" = let f () = try let fs = fs |> U.mkdir ~clock ~path in - let tm = mtime path fs in + let tm = U.mtime ~path fs in tm |> Float.to_string |> print_endline; fs |> T.tree |> print_endline with @@ -87,7 +83,7 @@ let%expect_test "mkdir_p" = let f () = try let fs = fs |> U.mkdir_p ~clock ~path in - let tm = mtime path fs in + let tm = U.mtime ~path fs in tm |> Float.to_string |> print_endline; fs |> T.tree |> print_endline with @@ -104,7 +100,7 @@ let%expect_test "mkdir_p" = let f () = try let fs = fs |> U.mkdir_p ~clock ~path in - let tm = mtime path fs in + let tm = U.mtime ~path fs in tm |> Float.to_string |> print_endline; fs |> T.tree |> print_endline with @@ -134,7 +130,7 @@ let%expect_test "mkdir_p" = let f () = try let fs = fs |> U.mkdir_p ~clock ~path in - let tm = mtime path fs in + let tm = U.mtime ~path fs in tm |> Float.to_string |> print_endline; fs |> T.tree |> print_endline with From 3b0d056bb36aee5527287aaf10b2c673e72b6aa6 Mon Sep 17 00:00:00 2001 From: xhtmlboi Date: Wed, 11 Feb 2026 11:47:06 +0100 Subject: [PATCH 04/12] Rename `Dummy` to `Simple` --- lib/tree.ml | 18 +++++++++--------- lib/tree.mli | 10 +++++----- test/mkdir_test.ml | 14 +++++++------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/lib/tree.ml b/lib/tree.ml index e9c4264..7f3fb00 100644 --- a/lib/tree.ml +++ b/lib/tree.ml @@ -281,7 +281,7 @@ let cat ~to_string fs path = | Some (File { content; _ }) -> to_string content ;; -module Dummy = struct +module Simple = struct (* NOTE: A very minimal implementation of a file system that shares some naive characteristics with Unix. As the purpose is to be used primarily for testing, its support is fairly basic. *) @@ -297,7 +297,7 @@ module Dummy = struct | Mkdir of Path.t * string | Stat of Path.t * string - exception Dummy_tree of error + exception Simple_error of error let err_file_exists = "File exists" let err_no_such_target = "No such file or directory" @@ -306,21 +306,21 @@ module Dummy = struct prim ^ ": " ^ err ^ " '" ^ Path.to_string path ^ "': " ^ reason ;; - let raise_error error = raise (Dummy_tree error) - let dummy_clock x _ = x + let raise_error error = raise (Simple_error error) + let const_clock x _ = x - let mount ?(clock = dummy_clock 1.0) ~scope children = + let mount ?(clock = const_clock 1.0) ~scope children = make ~scope_metadata:(fun path -> Some { mtime = clock path }) ~scope children ;; - let file ?(clock = dummy_clock 1.0) ~name content = + let file ?(clock = const_clock 1.0) ~name content = file ~metadata:{ mtime = clock (name, content) } ~name content ;; - let dir ?(clock = dummy_clock 1.0) ~name children = + let dir ?(clock = const_clock 1.0) ~name children = dir ~metadata:{ mtime = clock name } ~name children ;; @@ -329,7 +329,7 @@ module Dummy = struct | Stat (p, reason) -> error_s p "stat" "cannot statx" reason ;; - let mkdir ?(clock = dummy_clock 1.0) ~path fs = + let mkdir ?(clock = const_clock 1.0) ~path fs = let dname = Path.dirname path in match fetch ~path:dname fs, fetch ~path fs with | Some _, None -> @@ -344,7 +344,7 @@ module Dummy = struct | None, _ -> raise_error (Mkdir (path, err_no_such_target)) ;; - let mkdir_p ?(clock = dummy_clock 1.0) ~path fs = + let mkdir_p ?(clock = const_clock 1.0) ~path fs = let rec aux path fs = let file = fetch ~path fs in match file with diff --git a/lib/tree.mli b/lib/tree.mli index 5405f2b..49ee3ca 100644 --- a/lib/tree.mli +++ b/lib/tree.mli @@ -158,9 +158,9 @@ val cat : to_string:('a -> string) -> ('a, 'metadata) t -> Path.t -> string (** {1 A Dummy File System Implementation} The implementation is not abstract, which allows generic functions - to be used on a [Dummy] tree. *) + to be used on a [Simple] tree (mostly used for tests). *) -module Dummy : sig +module Simple : sig (** A truly {b very naive} implementation of a file system where the contents of files are strings and their metadata only associates modification dates. *) @@ -196,16 +196,16 @@ module Dummy : sig (** Set of all possible errors. *) type error - exception Dummy_tree of error + exception Simple_error of error (** Render an error as an Unix-like error message. *) val error_to_string : error -> string (** {1 Tree construction} *) - (** [dummy_clock f] creates a constant clock, always returning + (** [const_clock f] creates a constant clock, always returning [f]. *) - val dummy_clock : float -> 'a clock + val const_clock : float -> 'a clock (** [mount ?clock ~scope children] creates a tree using {!val:make}. *) val mount : ?clock:Path.t clock -> scope:Path.t -> item list -> t diff --git a/test/mkdir_test.ml b/test/mkdir_test.ml index a70168e..cc4c82e 100644 --- a/test/mkdir_test.ml +++ b/test/mkdir_test.ml @@ -4,7 +4,7 @@ SPDX-License-Identifier: BSD-3-Clause *) module T = Tree -module U = T.Dummy +module U = T.Simple let fs = let open U in @@ -28,7 +28,7 @@ let%expect_test "mkdir when nested path does not exists" = let path = Path.abs [ "1-foo"; "bar"; "baz"; "a-directory" ] in let f () = try fs |> U.mkdir ~clock ~path |> T.tree |> print_endline with - | U.Dummy_tree err -> err |> U.error_to_string |> print_endline + | U.Simple_error err -> err |> U.error_to_string |> print_endline in f (); [%expect @@ -40,7 +40,7 @@ let%expect_test "mkdir when target exists" = let path = Path.abs [ "1-foo"; "bar" ] in let f () = try fs |> U.mkdir ~clock ~path |> T.tree |> print_endline with - | U.Dummy_tree err -> err |> U.error_to_string |> print_endline + | U.Simple_error err -> err |> U.error_to_string |> print_endline in f (); [%expect {| mkdir: cannot create directory '/1-foo/bar': File exists |}] @@ -56,7 +56,7 @@ let%expect_test "mkdir" = tm |> Float.to_string |> print_endline; fs |> T.tree |> print_endline with - | U.Dummy_tree err -> err |> U.error_to_string |> print_endline + | U.Simple_error err -> err |> U.error_to_string |> print_endline in f (); [%expect @@ -87,7 +87,7 @@ let%expect_test "mkdir_p" = tm |> Float.to_string |> print_endline; fs |> T.tree |> print_endline with - | U.Dummy_tree err -> err |> U.error_to_string |> print_endline + | U.Simple_error err -> err |> U.error_to_string |> print_endline in f (); [%expect @@ -104,7 +104,7 @@ let%expect_test "mkdir_p" = tm |> Float.to_string |> print_endline; fs |> T.tree |> print_endline with - | U.Dummy_tree err -> err |> U.error_to_string |> print_endline + | U.Simple_error err -> err |> U.error_to_string |> print_endline in f (); [%expect @@ -134,7 +134,7 @@ let%expect_test "mkdir_p" = tm |> Float.to_string |> print_endline; fs |> T.tree |> print_endline with - | U.Dummy_tree err -> err |> U.error_to_string |> print_endline + | U.Simple_error err -> err |> U.error_to_string |> print_endline in f (); [%expect From ceb6d5b41cb2c845adc2f66dd23f12bf7219ac13 Mon Sep 17 00:00:00 2001 From: xhtmlboi Date: Wed, 11 Feb 2026 11:50:25 +0100 Subject: [PATCH 05/12] Allow `recursive` flag for `mkdir` instead of `mkdir_p` Co-authored-by: mspwn --- lib/tree.ml | 12 +++++++++--- lib/tree.mli | 19 ++++++++++--------- test/mkdir_test.ml | 6 +++--- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/lib/tree.ml b/lib/tree.ml index 7f3fb00..3d4c125 100644 --- a/lib/tree.ml +++ b/lib/tree.ml @@ -329,7 +329,7 @@ module Simple = struct | Stat (p, reason) -> error_s p "stat" "cannot statx" reason ;; - let mkdir ?(clock = const_clock 1.0) ~path fs = + let create_dir ?(clock = const_clock 1.0) ~path fs = let dname = Path.dirname path in match fetch ~path:dname fs, fetch ~path fs with | Some _, None -> @@ -344,7 +344,7 @@ module Simple = struct | None, _ -> raise_error (Mkdir (path, err_no_such_target)) ;; - let mkdir_p ?(clock = const_clock 1.0) ~path fs = + let create_dir_rec ?(clock = const_clock 1.0) ~path fs = let rec aux path fs = let file = fetch ~path fs in match file with @@ -353,11 +353,17 @@ module Simple = struct | None -> let p = Path.dirname path in let fs = aux p fs in - mkdir ~clock ~path fs + create_dir ~clock ~path fs in aux path fs ;; + let mkdir ?(recursive = false) ?(clock = const_clock 1.0) ~path fs = + if recursive + then create_dir_rec ~clock ~path fs + else create_dir ~clock ~path fs + ;; + let mtime ~path fs = match fetch ~path fs with | Some item -> diff --git a/lib/tree.mli b/lib/tree.mli index 49ee3ca..dde70f8 100644 --- a/lib/tree.mli +++ b/lib/tree.mli @@ -222,13 +222,14 @@ module Simple : sig {!type:item} located at the given [path]. *) val mtime : path:Path.t -> t -> float - (** [mkdir ?clock ~path] creates the directory referenced by the - given [path] with behaviour similar to the Unix command - [mkdir]. *) - val mkdir : ?clock:(content -> time) -> path:Path.t -> t -> t - - (** [mkdir_p ?clock ~path] creates the directory referenced by the - given [path] with behaviour similar to the Unix command - [mkdir -p]. *) - val mkdir_p : ?clock:(content -> time) -> path:Path.t -> t -> t + (** [mkdir ?recursive ?clock ~path] creates the directory referenced + by the given [path] with behaviour similar to the Unix command + [mkdir] (the [recursive] flag is for [mkdir -p], default is + [false]). *) + val mkdir + : ?recursive:bool + -> ?clock:(content -> time) + -> path:Path.t + -> t + -> t end diff --git a/test/mkdir_test.ml b/test/mkdir_test.ml index cc4c82e..97b9398 100644 --- a/test/mkdir_test.ml +++ b/test/mkdir_test.ml @@ -82,7 +82,7 @@ let%expect_test "mkdir_p" = let path = Path.abs [ "1-foo"; "bar"; "index.md" ] in let f () = try - let fs = fs |> U.mkdir_p ~clock ~path in + let fs = fs |> U.mkdir ~recursive:true ~clock ~path in let tm = U.mtime ~path fs in tm |> Float.to_string |> print_endline; fs |> T.tree |> print_endline @@ -99,7 +99,7 @@ let%expect_test "mkdir_p" = let path = Path.abs [ "1-foo"; "bar" ] in let f () = try - let fs = fs |> U.mkdir_p ~clock ~path in + let fs = fs |> U.mkdir ~recursive:true ~clock ~path in let tm = U.mtime ~path fs in tm |> Float.to_string |> print_endline; fs |> T.tree |> print_endline @@ -129,7 +129,7 @@ let%expect_test "mkdir_p" = let path = Path.abs [ "4-foo"; "bar"; "baz"; "storage" ] in let f () = try - let fs = fs |> U.mkdir_p ~clock ~path in + let fs = fs |> U.mkdir ~recursive:true ~clock ~path in let tm = U.mtime ~path fs in tm |> Float.to_string |> print_endline; fs |> T.tree |> print_endline From 2b7f948a0b12c1168d476a2b11f1287235e86d28 Mon Sep 17 00:00:00 2001 From: xhtmlboi Date: Wed, 11 Feb 2026 12:48:27 +0100 Subject: [PATCH 06/12] Use proper `handler` for `Tree.Simple` Co-authored-by: mspwn --- lib/tree.ml | 44 ++++++++++++++++++++++++ lib/tree.mli | 28 +++++++++++++++- test/dune | 3 +- test/mkdir_test.ml | 84 +++++++++++++++++++--------------------------- 4 files changed, 108 insertions(+), 51 deletions(-) diff --git a/lib/tree.ml b/lib/tree.ml index 3d4c125..902e682 100644 --- a/lib/tree.ml +++ b/lib/tree.ml @@ -296,12 +296,18 @@ module Simple = struct type error = | Mkdir of Path.t * string | Stat of Path.t * string + | Create_file of Path.t * string + | Read_file of Path.t * string exception Simple_error of error let err_file_exists = "File exists" let err_no_such_target = "No such file or directory" + (* let err_is_file = "Is a file" *) + let err_is_directory = "Is a directory" + let err_overriden = "Cannot be overridden" + let error_s path prim err reason = prim ^ ": " ^ err ^ " '" ^ Path.to_string path ^ "': " ^ reason ;; @@ -327,6 +333,14 @@ module Simple = struct let error_to_string = function | Mkdir (p, reason) -> error_s p "mkdir" "cannot create directory" reason | Stat (p, reason) -> error_s p "stat" "cannot statx" reason + | Create_file (p, reason) -> + error_s p "create_file" "cannot create file" reason + | Read_file (p, reason) -> error_s p "read_file" "cannot read file" reason + ;; + + let run ?(finalizer = fun _ -> ()) callback = + try finalizer (callback ()) with + | Simple_error err -> err |> error_to_string |> prerr_endline ;; let create_dir ?(clock = const_clock 1.0) ~path fs = @@ -374,4 +388,34 @@ module Simple = struct ~some:(fun { mtime } -> mtime) | _ -> raise_error (Stat (path, err_no_such_target)) ;; + + let write_file + ?(overwrite = false) + ?(clock = const_clock 1.0) + ~path + content + fs + = + let parent = Path.dirname path in + match fetch ~path fs, fetch ~path:parent fs with + | None, _ -> raise_error (Create_file (path, err_no_such_target)) + | Some _, Some (Directory _) -> + raise_error (Create_file (path, err_is_directory)) + | Some _, Some (File _) when not overwrite -> + raise_error (Create_file (path, err_overriden)) + | Some _, (Some _ | None) -> + update + ~path + (fun ~previous:_ ~path -> + let name = Path.basename path in + Some (file ~clock ~name content)) + fs + ;; + + let read_file ~path fs = + match fetch ~path fs with + | None -> raise_error (Read_file (path, err_no_such_target)) + | Some (Directory _) -> raise_error (Read_file (path, err_is_directory)) + | Some (File { content; _ }) -> content + ;; end diff --git a/lib/tree.mli b/lib/tree.mli index dde70f8..6261114 100644 --- a/lib/tree.mli +++ b/lib/tree.mli @@ -163,7 +163,10 @@ val cat : to_string:('a -> string) -> ('a, 'metadata) t -> Path.t -> string module Simple : sig (** A truly {b very naive} implementation of a file system where the contents of files are strings and their metadata only associates - modification dates. *) + modification dates. + + The API throws {!exception:Simple_error} exceptions to mimic + Unix behaviour. *) (** {1 Types} *) @@ -218,6 +221,8 @@ module Simple : sig parametrized by the [name] of the directory. *) val dir : ?clock:string clock -> name:string -> item list -> item + (** {1 Tree operation} *) + (** [mtime ~path fs] returns the {i modification time} of the given {!type:item} located at the given [path]. *) val mtime : path:Path.t -> t -> float @@ -232,4 +237,25 @@ module Simple : sig -> path:Path.t -> t -> t + + (** [write_file ?overwrite ?clock ~path content fs] creates (or + overwrites, depending on the [overwrite] flag, default [false]) + the file [path] with content [content] on the given [fs].*) + val write_file + : ?overwrite:bool + -> ?clock:(string * content -> time) + -> path:Path.t + -> string + -> t + -> t + + (** [read_file ~path fs] Reads the contents of the file referenced + by its [path] for a given [fs]. *) + val read_file : path:Path.t -> t -> string + + (** {1 Misc} *) + + (** [run ?finalizer callback] runs [callback] and print errors on + [stderr]. *) + val run : ?finalizer:('a -> unit) -> (unit -> 'a) -> unit end diff --git a/test/dune b/test/dune index d273b1c..1a7cc84 100644 --- a/test/dune +++ b/test/dune @@ -10,6 +10,7 @@ yocaml_www_resolver tree_core_test tree_update_test - mkdir_test) + mkdir_test + create_read_file_test) (preprocess (pps ppx_expect))) diff --git a/test/mkdir_test.ml b/test/mkdir_test.ml index 97b9398..cc2f28a 100644 --- a/test/mkdir_test.ml +++ b/test/mkdir_test.ml @@ -23,14 +23,12 @@ let fs = ] ;; +let finalizer fs = fs |> T.tree |> print_endline + let%expect_test "mkdir when nested path does not exists" = let clock _ = 2.0 in let path = Path.abs [ "1-foo"; "bar"; "baz"; "a-directory" ] in - let f () = - try fs |> U.mkdir ~clock ~path |> T.tree |> print_endline with - | U.Simple_error err -> err |> U.error_to_string |> print_endline - in - f (); + U.run ~finalizer (fun () -> fs |> U.mkdir ~clock ~path); [%expect {| mkdir: cannot create directory '/1-foo/bar/baz/a-directory': No such file or directory |}] ;; @@ -38,27 +36,21 @@ let%expect_test "mkdir when nested path does not exists" = let%expect_test "mkdir when target exists" = let clock _ = 2.0 in let path = Path.abs [ "1-foo"; "bar" ] in - let f () = - try fs |> U.mkdir ~clock ~path |> T.tree |> print_endline with - | U.Simple_error err -> err |> U.error_to_string |> print_endline - in - f (); + U.run (fun () -> fs |> U.mkdir ~clock ~path); [%expect {| mkdir: cannot create directory '/1-foo/bar': File exists |}] ;; let%expect_test "mkdir" = let clock _ = 2.0 in let path = Path.abs [ "1-foo"; "bar"; "storage" ] in - let f () = - try - let fs = fs |> U.mkdir ~clock ~path in - let tm = U.mtime ~path fs in + U.run + (fun () -> + let fs = fs |> U.mkdir ~clock ~path in + let tm = U.mtime ~path fs in + fs, tm) + ~finalizer:(fun (fs, tm) -> tm |> Float.to_string |> print_endline; - fs |> T.tree |> print_endline - with - | U.Simple_error err -> err |> U.error_to_string |> print_endline - in - f (); + fs |> T.tree |> print_endline); [%expect {| 2. @@ -77,36 +69,32 @@ let%expect_test "mkdir" = |}] ;; -let%expect_test "mkdir_p" = +let%expect_test "mkdir recursive" = let clock _ = 5.0 in let path = Path.abs [ "1-foo"; "bar"; "index.md" ] in - let f () = - try - let fs = fs |> U.mkdir ~recursive:true ~clock ~path in - let tm = U.mtime ~path fs in + U.run + (fun () -> + let fs = fs |> U.mkdir ~recursive:true ~clock ~path in + let tm = U.mtime ~path fs in + fs, tm) + ~finalizer:(fun (fs, tm) -> tm |> Float.to_string |> print_endline; - fs |> T.tree |> print_endline - with - | U.Simple_error err -> err |> U.error_to_string |> print_endline - in - f (); + fs |> T.tree |> print_endline); [%expect {| mkdir: cannot create directory '/1-foo/bar/index.md': File exists |}] ;; -let%expect_test "mkdir_p" = +let%expect_test "mkdir recursive" = let clock _ = 5.0 in let path = Path.abs [ "1-foo"; "bar" ] in - let f () = - try - let fs = fs |> U.mkdir ~recursive:true ~clock ~path in - let tm = U.mtime ~path fs in + U.run + (fun () -> + let fs = fs |> U.mkdir ~recursive:true ~clock ~path in + let tm = U.mtime ~path fs in + fs, tm) + ~finalizer:(fun (fs, tm) -> tm |> Float.to_string |> print_endline; - fs |> T.tree |> print_endline - with - | U.Simple_error err -> err |> U.error_to_string |> print_endline - in - f (); + fs |> T.tree |> print_endline); [%expect {| 1. @@ -124,19 +112,17 @@ let%expect_test "mkdir_p" = |}] ;; -let%expect_test "mkdir_p" = +let%expect_test "mkdir recursive" = let clock _ = 5.0 in let path = Path.abs [ "4-foo"; "bar"; "baz"; "storage" ] in - let f () = - try - let fs = fs |> U.mkdir ~recursive:true ~clock ~path in - let tm = U.mtime ~path fs in + U.run + (fun () -> + let fs = fs |> U.mkdir ~recursive:true ~clock ~path in + let tm = U.mtime ~path fs in + fs, tm) + ~finalizer:(fun (fs, tm) -> tm |> Float.to_string |> print_endline; - fs |> T.tree |> print_endline - with - | U.Simple_error err -> err |> U.error_to_string |> print_endline - in - f (); + fs |> T.tree |> print_endline); [%expect {| 5. From 09a46569bb2c77cf0e7073f2cc9ffdfe1487f888 Mon Sep 17 00:00:00 2001 From: xhtmlboi Date: Wed, 11 Feb 2026 13:05:50 +0100 Subject: [PATCH 07/12] Fix `write_file` and add more tests Co-authored-by: mspwn --- lib/tree.ml | 2 +- test/create_read_file_test.ml | 150 ++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 test/create_read_file_test.ml diff --git a/lib/tree.ml b/lib/tree.ml index 902e682..098910e 100644 --- a/lib/tree.ml +++ b/lib/tree.ml @@ -397,7 +397,7 @@ module Simple = struct fs = let parent = Path.dirname path in - match fetch ~path fs, fetch ~path:parent fs with + match fetch ~path:parent fs, fetch ~path fs with | None, _ -> raise_error (Create_file (path, err_no_such_target)) | Some _, Some (Directory _) -> raise_error (Create_file (path, err_is_directory)) diff --git a/test/create_read_file_test.ml b/test/create_read_file_test.ml new file mode 100644 index 0000000..5f48739 --- /dev/null +++ b/test/create_read_file_test.ml @@ -0,0 +1,150 @@ +(* Copyright (c) 2026, Cargocut and the Virtfs developers. + All rights reserved. + + SPDX-License-Identifier: BSD-3-Clause *) + +module T = Tree +module U = T.Simple + +let fs = + let open U in + mount + ~scope:Path.root + [ dir + ~name:"1-foo" + [ dir ~name:"bar" [ file ~name:"index.md" "Hello World" ] ] + ; dir + ~name:"2-foo" + [ dir + ~name:"bar" + [ dir ~name:"baz" [ file ~name:"index.md" "Hello World 2" ] ] + ] + ; dir ~name:"3-foo" [ dir ~name:"bar" [] ] + ] +;; + +let finalizer (fs, tm, ct) = + print_endline ("mtime: " ^ Float.to_string tm); + print_endline ct; + fs |> T.tree |> print_endline +;; + +let%expect_test "writing on a directory" = + let clock _ = 5.0 + and path = Path.abs [ "1-foo"; "bar" ] in + U.run + (fun () -> + let fs = fs |> U.write_file ~clock ~path "My new content" in + let tm = U.mtime ~path fs + and ct = U.read_file ~path fs in + fs, tm, ct) + ~finalizer; + [%expect {| create_file: cannot create file '/1-foo/bar': Is a directory |}] +;; + +let%expect_test "writing a file in an invalid path" = + let clock _ = 5.0 + and path = Path.abs [ "4-foo"; "index.md" ] in + U.run + (fun () -> + let fs = fs |> U.write_file ~clock ~path "My new content" in + let tm = U.mtime ~path fs + and ct = U.read_file ~path fs in + fs, tm, ct) + ~finalizer; + [%expect + {| create_file: cannot create file '/4-foo/index.md': No such file or directory |}] +;; + +let%expect_test "writing on an existing file (without overwrite flag)" = + let clock _ = 5.0 + and path = Path.abs [ "1-foo"; "bar"; "index.md" ] in + U.run + (fun () -> + let fs = fs |> U.write_file ~clock ~path "My new content" in + let tm = U.mtime ~path fs + and ct = U.read_file ~path fs in + fs, tm, ct) + ~finalizer; + [%expect + {| create_file: cannot create file '/1-foo/bar/index.md': Cannot be overridden |}] +;; + +let%expect_test "writing on an existing file (with overwrite flag)" = + let clock _ = 5.0 + and path = Path.abs [ "1-foo"; "bar"; "index.md" ] in + U.run + (fun () -> + let fs = + fs |> U.write_file ~overwrite:true ~clock ~path "My new content" + in + let tm = U.mtime ~path fs + and ct = U.read_file ~path fs in + fs, tm, ct) + ~finalizer; + [%expect + {| + mtime: 5. + My new content + + └─/ + └─1-foo/ + └─bar/ + └─index.md + └─2-foo/ + └─bar/ + └─baz/ + └─index.md + └─3-foo/ + └─bar/ + |}] +;; + +let%expect_test "writing on a new file" = + let clock _ = 5.0 + and path = Path.abs [ "1-foo"; "bar"; "index-2.md" ] in + U.run + (fun () -> + let fs = fs |> U.write_file ~clock ~path "My new content" in + let tm = U.mtime ~path fs + and ct = U.read_file ~path fs in + fs, tm, ct) + ~finalizer; + [%expect + {| + mtime: 5. + My new content + + └─/ + └─1-foo/ + └─bar/ + └─index-2.md + └─index.md + └─2-foo/ + └─bar/ + └─baz/ + └─index.md + └─3-foo/ + └─bar/ + |}] +;; + +let%expect_test "read_file on a directory" = + let path = Path.abs [ "1-foo"; "bar" ] in + U.run (fun () -> U.read_file ~path fs) ~finalizer:print_endline; + [%expect {| read_file: cannot read file '/1-foo/bar': Is a directory |}] +;; + +let%expect_test "read_file when not exists" = + let path = Path.abs [ "1-foo"; "bar.md" ] in + U.run (fun () -> U.read_file ~path fs) ~finalizer:print_endline; + [%expect + {| read_file: cannot read file '/1-foo/bar.md': No such file or directory |}] +;; + +let%expect_test "read_file" = + let path = Path.abs [ "1-foo"; "bar"; "index.md" ] in + U.run (fun () -> U.read_file ~path fs) ~finalizer:print_endline; + [%expect + {| Hello World |}] +;; From 94bde1754a164d797b801e8eebc08ca6ef07618a Mon Sep 17 00:00:00 2001 From: xhtmlboi Date: Wed, 11 Feb 2026 14:47:38 +0100 Subject: [PATCH 08/12] Add `read_dir` and `is_empty_dir` Co-authored-by: mspwn --- lib/tree.ml | 55 ++++++++++++++-- lib/tree.mli | 36 +++++++++++ test/create_read_file_test.ml | 3 +- test/dune | 3 +- test/read_dir_test.ml | 117 ++++++++++++++++++++++++++++++++++ 5 files changed, 204 insertions(+), 10 deletions(-) create mode 100644 test/read_dir_test.ml diff --git a/lib/tree.ml b/lib/tree.ml index 098910e..eff4e8e 100644 --- a/lib/tree.ml +++ b/lib/tree.ml @@ -296,15 +296,15 @@ module Simple = struct type error = | Mkdir of Path.t * string | Stat of Path.t * string - | Create_file of Path.t * string + | Write_file of Path.t * string | Read_file of Path.t * string + | Read_dir of Path.t * string exception Simple_error of error let err_file_exists = "File exists" let err_no_such_target = "No such file or directory" - - (* let err_is_file = "Is a file" *) + let err_is_file = "Is a file" let err_is_directory = "Is a directory" let err_overriden = "Cannot be overridden" @@ -333,9 +333,11 @@ module Simple = struct let error_to_string = function | Mkdir (p, reason) -> error_s p "mkdir" "cannot create directory" reason | Stat (p, reason) -> error_s p "stat" "cannot statx" reason - | Create_file (p, reason) -> + | Write_file (p, reason) -> error_s p "create_file" "cannot create file" reason | Read_file (p, reason) -> error_s p "read_file" "cannot read file" reason + | Read_dir (p, reason) -> + error_s p "read_dir" "cannot read directory" reason ;; let run ?(finalizer = fun _ -> ()) callback = @@ -398,11 +400,11 @@ module Simple = struct = let parent = Path.dirname path in match fetch ~path:parent fs, fetch ~path fs with - | None, _ -> raise_error (Create_file (path, err_no_such_target)) + | None, _ -> raise_error (Write_file (path, err_no_such_target)) | Some _, Some (Directory _) -> - raise_error (Create_file (path, err_is_directory)) + raise_error (Write_file (path, err_is_directory)) | Some _, Some (File _) when not overwrite -> - raise_error (Create_file (path, err_overriden)) + raise_error (Write_file (path, err_overriden)) | Some _, (Some _ | None) -> update ~path @@ -418,4 +420,43 @@ module Simple = struct | Some (Directory _) -> raise_error (Read_file (path, err_is_directory)) | Some (File { content; _ }) -> content ;; + + let file_exists ~path fs = + match fetch ~path fs with + | None -> false + | Some _ -> true + ;; + + let is_directory ~path fs = + match fetch ~path fs with + | None -> false + | Some item -> is_directory item + ;; + + let is_file ~path fs = + match fetch ~path fs with + | None -> false + | Some item -> is_file item + ;; + + let read_dir ~path fs = + match fetch ~path fs with + | None -> raise_error (Read_dir (path, err_no_such_target)) + | Some (File _) -> raise_error (Read_dir (path, err_is_file)) + | Some (Directory { content; _ }) -> + List.fold_left + (fun map elt -> + let key = Path.(path / name elt) in + Path.Map.add key elt map) + Path.Map.empty + content + ;; + + let is_empty_dir ~path fs = + match fetch ~path fs with + | None -> raise_error (Read_dir (path, err_no_such_target)) + | Some (File _) -> raise_error (Read_dir (path, err_is_file)) + | Some (Directory { content = []; _ }) -> true + | Some (Directory _) -> false + ;; end diff --git a/lib/tree.mli b/lib/tree.mli index 6261114..8fee525 100644 --- a/lib/tree.mli +++ b/lib/tree.mli @@ -227,6 +227,25 @@ module Simple : sig {!type:item} located at the given [path]. *) val mtime : path:Path.t -> t -> float + (* [file_exists ~path fs] returns [true] if the file/directory + exists at the given [path] for the given [fs], [false] + otherwise. *) + val file_exists : path:Path.t -> t -> bool + + (* [is_directory ~path fs] returns [true] if the directory + exists at the given [path] for the given [fs], [false] + otherwise (even if the target does not exists). *) + val is_directory : path:Path.t -> t -> bool + + (* [is_file ~path fs] returns [true] if the file + exists at the given [path] for the given [fs], [false] + otherwise (even if the target does not exists). *) + val is_file : path:Path.t -> t -> bool + + (** [is_empty_dir ~path fs] returns [true] if the directory located + at [path] for the given [fs] is an empty directory. *) + val is_empty_dir : path:Path.t -> t -> bool + (** [mkdir ?recursive ?clock ~path] creates the directory referenced by the given [path] with behaviour similar to the Unix command [mkdir] (the [recursive] flag is for [mkdir -p], default is @@ -238,6 +257,18 @@ module Simple : sig -> t -> t + (* (\** [rm ~path fs] remove the item by a given [path] (like *) + (* {!val:Tree.rm} but raising exception). *\) *) + (* val rm : path:Path.t -> t -> t *) + + (* (\** [rm_file fs path] remove the file by a given [path] (like *) + (* {!val:Tree.rm_file} but raising exception). *\) *) + (* val rm_file : path:Path.t -> t -> t *) + + (* (\** [rm_dir fs path] remove the directory by a given [path] (like *) + (* {!val:Tree.rm_dir} but raising exception). *\) *) + (* val rm_dir : path:Path.t -> t -> t *) + (** [write_file ?overwrite ?clock ~path content fs] creates (or overwrites, depending on the [overwrite] flag, default [false]) the file [path] with content [content] on the given [fs].*) @@ -253,6 +284,11 @@ module Simple : sig by its [path] for a given [fs]. *) val read_file : path:Path.t -> t -> string + (** [read_dir ~path fs] returns the direct children of the directory + passed as an argument (in the form of a map of {{!type:item} + items} indexed by {{!type:Path.t} Paths}).*) + val read_dir : path:Path.t -> t -> item Path.Map.t + (** {1 Misc} *) (** [run ?finalizer callback] runs [callback] and print errors on diff --git a/test/create_read_file_test.ml b/test/create_read_file_test.ml index 5f48739..2c5e0c1 100644 --- a/test/create_read_file_test.ml +++ b/test/create_read_file_test.ml @@ -145,6 +145,5 @@ let%expect_test "read_file when not exists" = let%expect_test "read_file" = let path = Path.abs [ "1-foo"; "bar"; "index.md" ] in U.run (fun () -> U.read_file ~path fs) ~finalizer:print_endline; - [%expect - {| Hello World |}] + [%expect {| Hello World |}] ;; diff --git a/test/dune b/test/dune index 1a7cc84..965c50b 100644 --- a/test/dune +++ b/test/dune @@ -11,6 +11,7 @@ tree_core_test tree_update_test mkdir_test - create_read_file_test) + create_read_file_test + read_dir_test) (preprocess (pps ppx_expect))) diff --git a/test/read_dir_test.ml b/test/read_dir_test.ml new file mode 100644 index 0000000..a9423af --- /dev/null +++ b/test/read_dir_test.ml @@ -0,0 +1,117 @@ +(* Copyright (c) 2026, Cargocut and the Virtfs developers. + All rights reserved. + + SPDX-License-Identifier: BSD-3-Clause *) + +module T = Tree +module U = T.Simple + +let fs = + let open U in + mount + ~scope:Path.root + [ dir + ~name:"1-foo" + [ dir + ~name:"bar" + [ file ~name:"index.md" "Hello World" + ; file ~name:"index-2.md" "Hello World" + ; file ~name:"index-3.md" "Hello World" + ; file ~name:"index-4.md" "Hello World" + ; file ~name:"index-5-final-version.md" "Hello World" + ] + ] + ; dir + ~name:"2-foo" + [ dir + ~name:"bar" + [ dir ~name:"baz" [ file ~name:"index.md" "Hello World 2" ] ] + ] + ; dir ~name:"3-foo" [ dir ~name:"bar" [] ] + ] +;; + +let finalizer = + Path.Map.iter (fun path elt -> + let ps = Path.to_string path in + let es = T.name elt in + ps ^ " -> " ^ es |> print_endline) +;; + +let%expect_test "read_directory without target" = + let path = Path.abs [ "4-foo" ] in + U.run (fun () -> U.read_dir ~path fs) ~finalizer; + [%expect + {| read_dir: cannot read directory '/4-foo': No such file or directory |}] +;; + +let%expect_test "read_directory on a file" = + let path = Path.abs [ "2-foo"; "bar"; "baz"; "index.md" ] in + U.run (fun () -> U.read_dir ~path fs) ~finalizer; + [%expect + {| read_dir: cannot read directory '/2-foo/bar/baz/index.md': Is a file |}] +;; + +let%expect_test "read_directory" = + let path = Path.root in + U.run (fun () -> U.read_dir ~path fs) ~finalizer; + [%expect + {| + /1-foo -> 1-foo/ + /2-foo -> 2-foo/ + /3-foo -> 3-foo/ + |}] +;; + +let%expect_test "read_directory" = + let path = Path.abs [ "1-foo" ] in + U.run (fun () -> U.read_dir ~path fs) ~finalizer; + [%expect {| /1-foo/bar -> bar/ |}] +;; + +let%expect_test "read_directory" = + let path = Path.abs [ "1-foo"; "bar" ] in + U.run (fun () -> U.read_dir ~path fs) ~finalizer; + [%expect + {| + /1-foo/bar/index-2.md -> index-2.md + /1-foo/bar/index-3.md -> index-3.md + /1-foo/bar/index-4.md -> index-4.md + /1-foo/bar/index-5-final-version.md -> index-5-final-version.md + /1-foo/bar/index.md -> index.md + |}] +;; + +let%expect_test "is_empty_dir on absent target" = + let path = Path.abs [ "0-foo" ] in + U.run + (fun () -> U.is_empty_dir ~path fs) + ~finalizer:(fun x -> x |> string_of_bool |> print_endline); + [%expect + {| read_dir: cannot read directory '/0-foo': No such file or directory |}] +;; + +let%expect_test "is_empty_dir on file" = + let path = Path.abs [ "1-foo"; "bar"; "index.md" ] in + U.run + (fun () -> U.is_empty_dir ~path fs) + ~finalizer:(fun x -> x |> string_of_bool |> print_endline); + [%expect + {| read_dir: cannot read directory '/1-foo/bar/index.md': Is a file |}] +;; + +let%expect_test "is_empty_dir on empty dir" = + let path = Path.abs [ "3-foo"; "bar" ] in + U.run + (fun () -> U.is_empty_dir ~path fs) + ~finalizer:(fun x -> x |> string_of_bool |> print_endline); + [%expect {| true |}] +;; + +let%expect_test "is_empty_dir on dir" = + let path = Path.abs [ "1-foo"; "bar" ] in + U.run + (fun () -> U.is_empty_dir ~path fs) + ~finalizer:(fun x -> x |> string_of_bool |> print_endline); + [%expect {| false |}] +;; From ac677a77fbc0d50927f5a48a17908870e930d36f Mon Sep 17 00:00:00 2001 From: xhtmlboi Date: Wed, 11 Feb 2026 17:01:30 +0100 Subject: [PATCH 09/12] Add `rm` (and consort) Co-authored-by: mspwn --- lib/tree.ml | 40 ++++++++++++ lib/tree.mli | 27 ++++---- test/dune | 3 +- test/rm_test.ml | 160 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 216 insertions(+), 14 deletions(-) create mode 100644 test/rm_test.ml diff --git a/lib/tree.ml b/lib/tree.ml index eff4e8e..c7a5666 100644 --- a/lib/tree.ml +++ b/lib/tree.ml @@ -299,6 +299,7 @@ module Simple = struct | Write_file of Path.t * string | Read_file of Path.t * string | Read_dir of Path.t * string + | Remove of Path.t * string exception Simple_error of error @@ -307,6 +308,7 @@ module Simple = struct let err_is_file = "Is a file" let err_is_directory = "Is a directory" let err_overriden = "Cannot be overridden" + let err_not_empty = "Directory not empty" let error_s path prim err reason = prim ^ ": " ^ err ^ " '" ^ Path.to_string path ^ "': " ^ reason @@ -338,6 +340,7 @@ module Simple = struct | Read_file (p, reason) -> error_s p "read_file" "cannot read file" reason | Read_dir (p, reason) -> error_s p "read_dir" "cannot read directory" reason + | Remove (p, reason) -> error_s p "rm" "cannot remove" reason ;; let run ?(finalizer = fun _ -> ()) callback = @@ -459,4 +462,41 @@ module Simple = struct | Some (Directory { content = []; _ }) -> true | Some (Directory _) -> false ;; + + let rm_file ~path fs = + match fetch ~path fs with + | None -> raise_error (Remove (path, err_no_such_target)) + | Some (Directory _) -> raise_error (Remove (path, err_is_directory)) + | Some (File _) -> rm_file ~path fs + ;; + + let generic_rm_dir = rm_dir + + let rec rm_dir ?(recursive = false) ~path fs = + if recursive + then ( + let rec aux path fs = + if is_file ~path fs + then rm_file ~path fs + else if is_empty_dir ~path fs + then rm_dir ~path fs + else ( + let fs = + Path.Map.fold (fun path _ fs -> aux path fs) (read_dir ~path fs) fs + in + rm_dir ~path fs) + in + aux path fs) + else if is_empty_dir ~path fs + then generic_rm_dir ~path fs + else raise_error (Remove (path, err_not_empty)) + ;; + + let rm ?(recursive = false) ~path fs = + if is_file ~path fs + then rm_file ~path fs + else if is_directory ~path fs + then rm_dir ~recursive ~path fs + else raise_error (Remove (path, err_no_such_target)) + ;; end diff --git a/lib/tree.mli b/lib/tree.mli index 8fee525..c5e5bc8 100644 --- a/lib/tree.mli +++ b/lib/tree.mli @@ -124,14 +124,15 @@ val touch (** [rm ~path fs] remove the item by a given [path]. *) val rm : path:Path.t -> ('a, 'metadata) t -> ('a, 'metadata) t -(** [rm_file fs path] remove the file by a given [path]. *) +(** [rm_file ~path fs] remove the file by a given [path]. *) val rm_file : path:Path.t -> ('a, 'metadata) t -> ('a, 'metadata) t -(** [rm_dir fs path] remove the directory by a given [path]. *) +(** [rm_dir ~path fs] remove the directory by a given [path]. *) val rm_dir : path:Path.t -> ('a, 'metadata) t -> ('a, 'metadata) t -(** [mv fs ~target ~source:p] move [p] as [target]. If the [target] exists, or - the given [p] does not exists, [fs] remains unchanged. *) +(** [mv fs ~target ~source] move [source] as [target]. If the [target] + exists, or the given [source] does not exists, [fs] remains + unchanged. *) val mv : target:Path.t -> source:Path.t @@ -257,17 +258,17 @@ module Simple : sig -> t -> t - (* (\** [rm ~path fs] remove the item by a given [path] (like *) - (* {!val:Tree.rm} but raising exception). *\) *) - (* val rm : path:Path.t -> t -> t *) + (** [rm ~path fs] remove the item by a given [path] (like + {!val:Tree.rm} but raising exception). *) + val rm : ?recursive:bool -> path:Path.t -> t -> t - (* (\** [rm_file fs path] remove the file by a given [path] (like *) - (* {!val:Tree.rm_file} but raising exception). *\) *) - (* val rm_file : path:Path.t -> t -> t *) + (** [rm_file fs path] remove the file by a given [path] (like + {!val:Tree.rm_file} but raising exception). *) + val rm_file : path:Path.t -> t -> t - (* (\** [rm_dir fs path] remove the directory by a given [path] (like *) - (* {!val:Tree.rm_dir} but raising exception). *\) *) - (* val rm_dir : path:Path.t -> t -> t *) + (** [rm_dir fs path] remove the directory by a given [path] (like + {!val:Tree.rm_dir} but raising exception). *) + val rm_dir : ?recursive:bool -> path:Path.t -> t -> t (** [write_file ?overwrite ?clock ~path content fs] creates (or overwrites, depending on the [overwrite] flag, default [false]) diff --git a/test/dune b/test/dune index 965c50b..2d016b2 100644 --- a/test/dune +++ b/test/dune @@ -12,6 +12,7 @@ tree_update_test mkdir_test create_read_file_test - read_dir_test) + read_dir_test + rm_test) (preprocess (pps ppx_expect))) diff --git a/test/rm_test.ml b/test/rm_test.ml new file mode 100644 index 0000000..beec273 --- /dev/null +++ b/test/rm_test.ml @@ -0,0 +1,160 @@ +(* Copyright (c) 2026, Cargocut and the Virtfs developers. + All rights reserved. + + SPDX-License-Identifier: BSD-3-Clause *) + +module T = Tree +module U = T.Simple + +let fs = + let open U in + mount + ~scope:Path.root + [ dir + ~name:"1-foo" + [ dir ~name:"bar" [ file ~name:"index.md" "Hello World" ] ] + ; dir + ~name:"2-foo" + [ dir + ~name:"bar" + [ dir ~name:"baz" [ file ~name:"index.md" "Hello World 2" ] ] + ] + ; dir ~name:"3-foo" [ dir ~name:"bar" [] ] + ] +;; + +let finalizer fs = fs |> T.tree |> print_endline + +let%expect_test "rm_file on a directory" = + let path = Path.abs [ "1-foo" ] in + U.run ~finalizer (fun () -> U.rm_file ~path fs); + [%expect {| rm: cannot remove '/1-foo': Is a directory |}] +;; + +let%expect_test "rm_file on absent" = + let path = Path.abs [ "1-foo"; "foo.md" ] in + U.run ~finalizer (fun () -> U.rm_file ~path fs); + [%expect {| rm: cannot remove '/1-foo/foo.md': No such file or directory |}] +;; + +let%expect_test "rm_file on a file" = + let path = Path.abs [ "1-foo"; "bar"; "index.md" ] in + U.run ~finalizer (fun () -> U.rm_file ~path fs); + [%expect + {| + └─/ + └─1-foo/ + └─bar/ + └─2-foo/ + └─bar/ + └─baz/ + └─index.md + └─3-foo/ + └─bar/ + |}] +;; + +let%expect_test "rm_dir on a file" = + let path = Path.abs [ "1-foo"; "bar"; "index.md" ] in + U.run ~finalizer (fun () -> U.rm_dir ~path fs); + [%expect + {| read_dir: cannot read directory '/1-foo/bar/index.md': Is a file |}] +;; + +let%expect_test "rm_dir on absent" = + let path = Path.abs [ "1-foo"; "baz" ] in + U.run ~finalizer (fun () -> U.rm_dir ~path fs); + [%expect + {| read_dir: cannot read directory '/1-foo/baz': No such file or directory |}] +;; + +let%expect_test "rm_dir on an non-empty directory" = + let path = Path.abs [ "1-foo" ] in + U.run ~finalizer (fun () -> U.rm_dir ~path fs); + [%expect {| rm: cannot remove '/1-foo': Directory not empty |}] +;; + +let%expect_test "rm_dir on an empty directory" = + let path = Path.abs [ "3-foo"; "bar" ] in + U.run ~finalizer (fun () -> U.rm_dir ~path fs); + [%expect + {| + └─/ + └─1-foo/ + └─bar/ + └─index.md + └─2-foo/ + └─bar/ + └─baz/ + └─index.md + └─3-foo/ + |}] +;; + +let%expect_test "rm on absent" = + let path = Path.abs [ "3-foo"; "baz" ] in + U.run ~finalizer (fun () -> U.rm ~path fs); + [%expect {| rm: cannot remove '/3-foo/baz': No such file or directory |}] +;; + +let%expect_test "rm on non-empty directory" = + let path = Path.abs [ "3-foo" ] in + U.run ~finalizer (fun () -> U.rm ~path fs); + [%expect {| rm: cannot remove '/3-foo': Directory not empty |}] +;; + +let%expect_test "rm on an empty directory" = + let path = Path.abs [ "3-foo"; "bar" ] in + U.run ~finalizer (fun () -> U.rm ~path fs); + [%expect + {| + └─/ + └─1-foo/ + └─bar/ + └─index.md + └─2-foo/ + └─bar/ + └─baz/ + └─index.md + └─3-foo/ + |}] +;; + +let%expect_test "rm on a file" = + let path = Path.abs [ "1-foo"; "bar"; "index.md" ] in + U.run ~finalizer (fun () -> U.rm ~path fs); + [%expect + {| + └─/ + └─1-foo/ + └─bar/ + └─2-foo/ + └─bar/ + └─baz/ + └─index.md + └─3-foo/ + └─bar/ + |}] +;; + +let%expect_test "rm-dir recursive" = + let path = Path.abs [ "1-foo" ] in + U.run ~finalizer (fun () -> U.rm_dir ~recursive:true ~path fs); + [%expect + {| + └─/ + └─2-foo/ + └─bar/ + └─baz/ + └─index.md + └─3-foo/ + └─bar/ + |}] +;; + +let%expect_test "rm-dir recursive" = + let path = Path.root in + U.run ~finalizer (fun () -> U.rm_dir ~recursive:true ~path fs); + [%expect + {| |}] +;; From e62c11a84f43a070f2a1182bbd51836ecee80f81 Mon Sep 17 00:00:00 2001 From: xhtmlboi Date: Wed, 11 Feb 2026 17:04:02 +0100 Subject: [PATCH 10/12] Add README Section --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 3778732..23bd1c4 100644 --- a/README.md +++ b/README.md @@ -23,3 +23,9 @@ single compact dependency, without dependencies (and without using the it easy to use in a [Js_of_ocaml](https://ocsigen.org/js_of_ocaml/latest/manual/overview) programme). + +The library offers a `Path` module for constructing globally portable +and resolvable file paths. A `Tree` module for describing a file tree, +and `Tree.Simple`, which mimics (approximately) the behaviour of a +very limited Unix file system (with only the modification date, +`mtime`, as metadata). From 681e23b0e488271bd2341a30baa966d09e5d03a9 Mon Sep 17 00:00:00 2001 From: xhtmlboi Date: Wed, 11 Feb 2026 17:06:28 +0100 Subject: [PATCH 11/12] Add CHANGE entry --- CHANGES.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 CHANGES.md diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..5205d69 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,6 @@ +### unreleased + +- First release of `virtfs`, exposing the modules `Path`, for + abstracting file paths, `Tree` for describing an abstract file tree, + and `Tree.Simple`, which is a very simple version of a file system + (where the contents of files are strings). From d6dd812dfc9b39c586f0621afff2c2804a6e7038 Mon Sep 17 00:00:00 2001 From: xhtmlboi Date: Wed, 11 Feb 2026 17:20:43 +0100 Subject: [PATCH 12/12] Chores pre-release --- lib/dune | 3 ++- lib/virtfs.mli | 27 +++++++++++++++++++++++++++ test/rm_test.ml | 3 +-- 3 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 lib/virtfs.mli diff --git a/lib/dune b/lib/dune index 4bd457a..1f611c9 100644 --- a/lib/dune +++ b/lib/dune @@ -1,6 +1,7 @@ (library (name virtfs) - (public_name virtfs)) + (public_name virtfs) + (modules_without_implementation virtfs)) (mdx (files *.mli) diff --git a/lib/virtfs.mli b/lib/virtfs.mli new file mode 100644 index 0000000..d5c0b03 --- /dev/null +++ b/lib/virtfs.mli @@ -0,0 +1,27 @@ +(* Copyright (c) 2026, Cargocut and the Virtfs developers. + All rights reserved. + + SPDX-License-Identifier: BSD-3-Clause *) + +(** [Virtfs] provides primitives for describing paths in a file system + in an {i abstract} (platform-independent) and {i relatively + portable} manner, while allowing virtual file systems to be easily + mounted to facilitate the writing of unit tests in programmes that + abstract effects and rely on the file system.*) + +(** {1 Abstraction over Path} + + Describes resolvable, portable, and platform-independent file + paths. *) + +module Path = Path + +(** {1 Virtual File System} + + Implementation of an {i in-memory} file system based on + {!module:Path}, which abstracts file contents and metadata. The + module also exposes {!module:Tree.Simple}, which is an opinionated + implementation of a very limited version of a system that mimics + (somewhat) the behaviour of Unix file systems. *) + +module Tree = Tree diff --git a/test/rm_test.ml b/test/rm_test.ml index beec273..09d2376 100644 --- a/test/rm_test.ml +++ b/test/rm_test.ml @@ -155,6 +155,5 @@ let%expect_test "rm-dir recursive" = let%expect_test "rm-dir recursive" = let path = Path.root in U.run ~finalizer (fun () -> U.rm_dir ~recursive:true ~path fs); - [%expect - {| |}] + [%expect {| |}] ;;