Add ork-server crate with virtfs EDSL

- virtfs: Declarative filesystem EDSL (port of ollie's pattern)
  - dir(), file(), each(), dir_dynamic() constructors
  - read(), write(), rdwr(), rw() handlers
  - FsNode with walk(), list(), read(), write()

- state: OrkState manages parsed documents
  - Scans org directory, parses on load
  - Section ID from CUSTOM_ID > ID > slugified title
  - Read/write document content

- namespace: 9P namespace builder
  - ctl, idx, keywords at root
  - agenda/todos, agenda/today, agenda/week
  - {doc}/ with raw, meta, and sections
  - {section}/ with headline, title, keyword, priority, tags
  - Nested children for subsections
This commit is contained in:
Levi Neely 2026-09-30 10:31:11 +02:00
parent 0c5f56aad9
commit 246bd9bb5a
7 changed files with 1715 additions and 5 deletions

713
Cargo.lock generated
View File

@ -2,6 +2,21 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "addr2line"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
dependencies = [
"gimli",
]
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aho-corasick"
version = "1.1.5"
@ -47,7 +62,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@ -58,9 +73,54 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "backtrace"
version = "0.3.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
dependencies = [
"addr2line",
"cfg-if",
"libc",
"miniz_oxide",
"object",
"rustc-demangle",
"windows-link",
]
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cc"
version = "1.5.1"
@ -71,6 +131,12 @@ dependencies = [
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "clap"
version = "4.6.7"
@ -102,7 +168,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
"syn 3.0.6",
]
[[package]]
@ -117,24 +183,133 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "crossbeam-channel"
version = "0.5.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "diff"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "failure"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d32e9bd16cc02eae7db7ef620b392808b89f6a5e16bb3497d159c6b92a0f4f86"
dependencies = [
"backtrace",
"failure_derive",
]
[[package]]
name = "failure_derive"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa4da3c766cd7a0db8242e326e9e4e081edd567072893ed320008189715366a4"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
"synstructure",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "gimli"
version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "inotify"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
dependencies = [
"bitflags 1.3.2",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
dependencies = [
"libc",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@ -147,12 +322,162 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "kqueue"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
dependencies = [
"bitflags 2.13.2",
"libc",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
]
[[package]]
name = "mio"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.48.0",
]
[[package]]
name = "mio"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
dependencies = [
"libc",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
name = "nine"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7eca0c850b4d39133ee5a1c0939027b0ef5c27c1927d90df3ce796c7ebdf49"
dependencies = [
"bitflags 1.3.2",
"byteorder",
"failure",
"serde",
"serde_derive",
]
[[package]]
name = "notify"
version = "6.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
dependencies = [
"bitflags 2.13.2",
"crossbeam-channel",
"filetime",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio 0.8.11",
"walkdir",
"windows-sys 0.48.0",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "object"
version = "0.37.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
@ -192,6 +517,23 @@ dependencies = [
"thiserror",
]
[[package]]
name = "ork-server"
version = "0.1.0"
dependencies = [
"anyhow",
"nine",
"notify",
"org-ast",
"org-parser",
"parking_lot",
"tempfile",
"thiserror",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "orkmode-core"
version = "0.1.0"
@ -204,6 +546,35 @@ dependencies = [
"thiserror",
]
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pretty_assertions"
version = "1.4.1"
@ -232,6 +603,21 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "regex"
version = "1.13.1"
@ -261,6 +647,40 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rustc-demangle"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb"
[[package]]
name = "rustix"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [
"bitflags 2.13.2",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serde"
version = "1.0.229"
@ -288,7 +708,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.6",
]
[[package]]
@ -304,18 +724,75 @@ dependencies = [
"zmij",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "smallvec"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "1.0.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.6"
@ -327,6 +804,31 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
"unicode-xid",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
version = "2.0.21"
@ -344,7 +846,105 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.6",
]
[[package]]
name = "thread_local"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
dependencies = [
"cfg-if",
]
[[package]]
name = "tokio"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"bytes",
"libc",
"mio 1.2.3",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
]
[[package]]
@ -363,18 +963,64 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@ -384,6 +1030,63 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "yansi"
version = "1.0.1"

View File

@ -5,6 +5,7 @@ members = [
"crates/org-ast",
"crates/orkmode-core",
"crates/ork-cli",
"crates/ork-server",
]
[workspace.package]

View File

@ -0,0 +1,33 @@
[package]
name = "ork-server"
version = "0.1.0"
edition = "2021"
license = "GPL-3.0-or-later"
description = "9P server for orkmode"
[dependencies]
org-ast = { path = "../org-ast" }
org-parser = { path = "../org-parser" }
# 9P protocol
nine = "0.2"
# Async runtime
tokio = { version = "1", features = ["full"] }
# Error handling
thiserror = "2"
anyhow = "1"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# File watching
notify = "6"
# Utilities
parking_lot = "0.12"
[dev-dependencies]
tempfile = "3"

View File

@ -0,0 +1,9 @@
//! Orkmode 9P server.
//!
//! Exposes org documents via 9P filesystem protocol.
pub mod virtfs;
pub mod state;
pub mod namespace;
pub use state::OrkState;

View File

@ -0,0 +1,292 @@
//! Build the 9P namespace from server state.
use crate::state::OrkState;
use crate::virtfs::{self, FsNode};
use std::sync::Arc;
/// Build the orkmode 9P namespace.
pub fn build_namespace(state: Arc<OrkState>) -> FsNode {
let s = state.clone();
let s2 = state.clone();
// Root directory with static files + dynamic doc directories
virtfs::dir_dynamic("/",
vec![
// ctl - control commands
virtfs::file("ctl", 0o222, virtfs::rdwr({
let state = state.clone();
move |data| {
let cmd = String::from_utf8_lossy(data);
state.handle_ctl(&cmd)
.map(|s| s.into_bytes())
}
})),
// idx - document index
virtfs::file("idx", 0o444, virtfs::read({
let state = state.clone();
move || Ok(state.doc_index().into_bytes())
})),
// keywords - TODO keywords
virtfs::file("keywords", 0o444, virtfs::read({
let state = state.clone();
move || Ok(state.keywords().into_bytes())
})),
// agenda - computed views
virtfs::dir("agenda", vec![
virtfs::file("todos", 0o444, virtfs::read({
let state = state.clone();
move || Ok(build_todos_view(&state).into_bytes())
})),
virtfs::file("today", 0o444, virtfs::read({
let state = state.clone();
move || Ok(build_today_view(&state).into_bytes())
})),
virtfs::file("week", 0o444, virtfs::read({
let state = state.clone();
move || Ok(build_week_view(&state).into_bytes())
})),
]),
],
// Dynamic children: document directories
move || Ok(s.doc_names()),
move |name| {
let doc_name = name.to_string();
Some(build_doc_node(s2.clone(), &doc_name))
},
)
}
/// Build a document node.
fn build_doc_node(state: Arc<OrkState>, doc_name: &str) -> FsNode {
let name = doc_name.to_string();
let s = state.clone();
let s2 = state.clone();
let n = name.clone();
let n2 = name.clone();
virtfs::dir_dynamic(&name,
vec![
// raw - full org content
virtfs::file("raw", 0o644, virtfs::rw(
{
let state = state.clone();
let name = name.clone();
move || {
state.doc_raw(&name)
.map(|s| s.into_bytes())
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "not found"))
}
},
{
let state = state.clone();
let name = name.clone();
move |data| {
let content = String::from_utf8_lossy(data);
state.write_doc_raw(&name, &content)
}
},
)),
// meta - document metadata
virtfs::file("meta", 0o444, virtfs::read({
let state = state.clone();
let name = name.clone();
move || {
state.doc_meta(&name)
.map(|s| s.into_bytes())
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "not found"))
}
})),
],
// Dynamic section directories
move || Ok(s.section_ids(&n)),
move |id| {
Some(build_section_node(s2.clone(), &n2, id))
},
)
}
/// Build a section node (recursive for children).
fn build_section_node(state: Arc<OrkState>, doc_name: &str, section_id: &str) -> FsNode {
let doc = doc_name.to_string();
let id = section_id.to_string();
let s1 = state.clone();
let s2 = state.clone();
let s3 = state.clone();
let s4 = state.clone();
let s5 = state.clone();
let s6 = state.clone();
let s7 = state.clone();
let d1 = doc.clone();
let d2 = doc.clone();
let d3 = doc.clone();
let d4 = doc.clone();
let d5 = doc.clone();
let d6 = doc.clone();
let d7 = doc.clone();
let i1 = id.clone();
let i2 = id.clone();
let i3 = id.clone();
let i4 = id.clone();
let i5 = id.clone();
let i6 = id.clone();
virtfs::dir_dynamic(&id,
vec![
// headline - full reconstructed headline
virtfs::file("headline", 0o444, virtfs::read(move || {
s1.section_headline(&d1, &i1)
.map(|s| format!("{}\n", s).into_bytes())
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "not found"))
})),
// title
virtfs::file("title", 0o644, virtfs::read(move || {
s2.section_title(&d2, &i2)
.map(|s| format!("{}\n", s).into_bytes())
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "not found"))
})),
// keyword (TODO/DONE/etc)
virtfs::file("keyword", 0o644, virtfs::read(move || {
Ok(s3.section_keyword(&d3, &i3)
.map(|s| format!("{}\n", s).into_bytes())
.unwrap_or_else(|| b"\n".to_vec()))
})),
// priority
virtfs::file("priority", 0o644, virtfs::read(move || {
Ok(s4.section_priority(&d4, &i4)
.map(|s| format!("{}\n", s).into_bytes())
.unwrap_or_else(|| b"\n".to_vec()))
})),
// tags
virtfs::file("tags", 0o644, virtfs::read(move || {
Ok(s5.section_tags(&d5, &i5)
.map(|s| format!("{}\n", s).into_bytes())
.unwrap_or_else(|| b"\n".to_vec()))
})),
],
// children - nested sections
move || Ok(s6.child_ids(&d6, &i6)),
move |child_id| {
Some(build_section_node(s7.clone(), &d7, child_id))
},
)
}
// ═══════════════════════════════════════════════════════════════════
// Agenda Views
// ═══════════════════════════════════════════════════════════════════
fn build_todos_view(state: &OrkState) -> String {
let mut lines = Vec::new();
for doc_name in state.doc_names() {
if let Some(doc_state) = state.get_doc(&doc_name) {
for section in doc_state.doc.all_sections() {
if section.is_todo() {
let id = if let Some(cid) = section.custom_id() {
cid.to_string()
} else {
section.headline.title_text().to_lowercase().replace(' ', "-")
};
let kw = section.headline.keyword.as_ref()
.map(|k| k.keyword.as_str())
.unwrap_or("");
lines.push(format!("{}\t{}\t{}\t{}",
id, doc_name, section.headline.title_text(), kw));
}
}
}
}
lines.sort();
lines.join("\n") + "\n"
}
fn build_today_view(state: &OrkState) -> String {
// TODO: implement with actual date filtering
build_todos_view(state)
}
fn build_week_view(state: &OrkState) -> String {
// TODO: implement with actual date filtering
build_todos_view(state)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_namespace_structure() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("test.org"), "#+TITLE: Test\n* TODO Task\n").unwrap();
let state = Arc::new(OrkState::new(dir.path()).unwrap());
let root = build_namespace(state);
// Check root files
assert!(root.walk("ctl").is_some());
assert!(root.walk("idx").is_some());
assert!(root.walk("keywords").is_some());
assert!(root.walk("agenda").is_some());
assert!(root.walk("agenda/todos").is_some());
// Check document
assert!(root.walk("test").is_some());
assert!(root.walk("test/raw").is_some());
assert!(root.walk("test/meta").is_some());
// Check section
assert!(root.walk("test/task").is_some());
assert!(root.walk("test/task/title").is_some());
assert!(root.walk("test/task/keyword").is_some());
}
#[test]
fn test_read_files() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("work.org"),
"#+TITLE: Work Tasks\n* TODO [#A] Important :urgent:\nSome body text\n").unwrap();
let state = Arc::new(OrkState::new(dir.path()).unwrap());
let root = build_namespace(state);
// Read idx
let idx = root.walk("idx").unwrap();
let content = String::from_utf8(idx.read().unwrap()).unwrap();
assert!(content.contains("work"));
assert!(content.contains("Work Tasks"));
// Read section title
let title = root.walk("work/important/title").unwrap();
let content = String::from_utf8(title.read().unwrap()).unwrap();
assert_eq!(content.trim(), "Important");
// Read keyword
let kw = root.walk("work/important/keyword").unwrap();
let content = String::from_utf8(kw.read().unwrap()).unwrap();
assert_eq!(content.trim(), "TODO");
// Read priority
let pri = root.walk("work/important/priority").unwrap();
let content = String::from_utf8(pri.read().unwrap()).unwrap();
assert_eq!(content.trim(), "A");
// Read tags
let tags = root.walk("work/important/tags").unwrap();
let content = String::from_utf8(tags.read().unwrap()).unwrap();
assert_eq!(content.trim(), "urgent");
}
}

View File

@ -0,0 +1,296 @@
//! Server state: manages org documents and their parsed state.
use org_ast::Document;
use org_parser::Parser;
use parking_lot::RwLock;
use std::collections::HashMap;
use std::fs;
use std::io::{self, Result};
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Parsed document with metadata.
pub struct DocState {
pub path: PathBuf,
pub content: String,
pub doc: Document,
}
/// Server state: all loaded documents.
pub struct OrkState {
/// Directory containing org files.
pub org_dir: PathBuf,
/// Parsed documents by name (without .org extension).
docs: RwLock<HashMap<String, Arc<DocState>>>,
/// Parser instance.
parser: RwLock<Parser>,
}
impl OrkState {
/// Create new state watching the given directory.
pub fn new(org_dir: impl AsRef<Path>) -> Result<Self> {
let org_dir = org_dir.as_ref().to_path_buf();
if !org_dir.is_dir() {
return Err(io::Error::new(io::ErrorKind::NotADirectory, "not a directory"));
}
let state = Self {
org_dir,
docs: RwLock::new(HashMap::new()),
parser: RwLock::new(Parser::new()),
};
state.scan()?;
Ok(state)
}
/// Scan org directory for files.
pub fn scan(&self) -> Result<()> {
let mut docs = self.docs.write();
docs.clear();
for entry in fs::read_dir(&self.org_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().map_or(false, |e| e == "org") {
if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
match self.load_doc(&path) {
Ok(doc_state) => {
docs.insert(name.to_string(), Arc::new(doc_state));
}
Err(e) => {
eprintln!("warning: failed to load {}: {}", path.display(), e);
}
}
}
}
}
Ok(())
}
/// Load and parse a single document.
fn load_doc(&self, path: &Path) -> Result<DocState> {
let content = fs::read_to_string(path)?;
let doc = self.parser.write().parse(&content)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
Ok(DocState {
path: path.to_path_buf(),
content,
doc,
})
}
/// List document names.
pub fn doc_names(&self) -> Vec<String> {
self.docs.read().keys().cloned().collect()
}
/// Get a document by name.
pub fn get_doc(&self, name: &str) -> Option<Arc<DocState>> {
self.docs.read().get(name).cloned()
}
/// Get document index: name\tpath\ttitle.
pub fn doc_index(&self) -> String {
let docs = self.docs.read();
let mut lines: Vec<String> = docs.iter()
.map(|(name, state)| {
let title = state.doc.title().unwrap_or("-");
format!("{}\t{}\t{}", name, state.path.display(), title)
})
.collect();
lines.sort();
lines.join("\n") + "\n"
}
/// Get raw content of a document.
pub fn doc_raw(&self, name: &str) -> Option<String> {
self.get_doc(name).map(|d| d.content.clone())
}
/// Write raw content to a document.
pub fn write_doc_raw(&self, name: &str, content: &str) -> Result<()> {
let doc_state = self.get_doc(name)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "document not found"))?;
// Parse first to validate
let new_doc = self.parser.write().parse(content)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
// Write to file
fs::write(&doc_state.path, content)?;
// Update state
let new_state = Arc::new(DocState {
path: doc_state.path.clone(),
content: content.to_string(),
doc: new_doc,
});
self.docs.write().insert(name.to_string(), new_state);
Ok(())
}
/// Get document metadata.
pub fn doc_meta(&self, name: &str) -> Option<String> {
self.get_doc(name).map(|d| {
let title = d.doc.title().unwrap_or("");
let author = d.doc.author().unwrap_or("");
let filetags = d.doc.settings.filetags.join(":");
format!("title\t{}\nauthor\t{}\nfiletags\t{}\n", title, author, filetags)
})
}
/// Get all TODO keywords configuration.
pub fn keywords(&self) -> String {
// Return default TODO keywords
// TODO: aggregate from all docs or use config
"TODO NEXT WAITING | DONE CANCELLED\n".to_string()
}
/// Handle control commands.
pub fn handle_ctl(&self, cmd: &str) -> Result<String> {
let cmd = cmd.trim();
match cmd {
"reload" | "rescan" => {
self.scan()?;
Ok("ok\n".to_string())
}
_ => Err(io::Error::new(io::ErrorKind::InvalidInput, format!("unknown command: {}", cmd))),
}
}
}
// ═══════════════════════════════════════════════════════════════════
// Section Access
// ═══════════════════════════════════════════════════════════════════
impl OrkState {
/// Get section IDs for a document (top-level only).
pub fn section_ids(&self, doc_name: &str) -> Vec<String> {
self.get_doc(doc_name)
.map(|d| {
d.doc.sections.iter()
.map(|s| self.section_id(s))
.collect()
})
.unwrap_or_default()
}
/// Get child section IDs.
pub fn child_ids(&self, doc_name: &str, parent_id: &str) -> Vec<String> {
self.get_doc(doc_name)
.and_then(|d| {
d.doc.all_sections()
.find(|s| self.section_id(s) == parent_id)
.map(|s| {
s.children.iter()
.map(|c| self.section_id(c))
.collect()
})
})
.unwrap_or_default()
}
/// Generate section ID: CUSTOM_ID > ID > slugified title.
fn section_id(&self, section: &org_ast::Section) -> String {
if let Some(id) = section.custom_id() {
return id.to_string();
}
if let Some(id) = section.id() {
return id.to_string();
}
// Slugify title
let title = section.headline.title_text();
slugify(&title)
}
/// Find section by ID in document.
pub fn find_section<'a>(&self, doc: &'a Document, id: &str) -> Option<&'a org_ast::Section> {
doc.all_sections().find(|s| self.section_id(s) == id)
}
/// Get section headline.
pub fn section_headline(&self, doc_name: &str, id: &str) -> Option<String> {
self.get_doc(doc_name).and_then(|d| {
self.find_section(&d.doc, id).map(|s| {
// Reconstruct headline
let mut line = "*".repeat(s.headline.level as usize);
line.push(' ');
if let Some(kw) = &s.headline.keyword {
line.push_str(&kw.keyword);
line.push(' ');
}
if let Some(p) = s.headline.priority {
line.push_str(&format!("[#{}] ", p.as_char()));
}
line.push_str(&s.headline.title_text());
if !s.headline.tags.is_empty() {
line.push_str(&format!(" :{}:", s.headline.tags.join(":")));
}
line
})
})
}
/// Get section title.
pub fn section_title(&self, doc_name: &str, id: &str) -> Option<String> {
self.get_doc(doc_name).and_then(|d| {
self.find_section(&d.doc, id).map(|s| s.headline.title_text())
})
}
/// Get section keyword.
pub fn section_keyword(&self, doc_name: &str, id: &str) -> Option<String> {
self.get_doc(doc_name).and_then(|d| {
self.find_section(&d.doc, id).and_then(|s| {
s.headline.keyword.as_ref().map(|k| k.keyword.clone())
})
})
}
/// Get section priority.
pub fn section_priority(&self, doc_name: &str, id: &str) -> Option<String> {
self.get_doc(doc_name).and_then(|d| {
self.find_section(&d.doc, id).and_then(|s| {
s.headline.priority.map(|p| p.as_char().to_string())
})
})
}
/// Get section tags.
pub fn section_tags(&self, doc_name: &str, id: &str) -> Option<String> {
self.get_doc(doc_name).and_then(|d| {
self.find_section(&d.doc, id).map(|s| s.headline.tags.join(":"))
})
}
}
/// Slugify a string for use as an ID.
fn slugify(s: &str) -> String {
s.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_slugify() {
assert_eq!(slugify("Hello World"), "hello-world");
assert_eq!(slugify("TODO: Fix bug #123"), "todo-fix-bug-123");
assert_eq!(slugify(" multiple spaces "), "multiple-spaces");
}
}

View File

@ -0,0 +1,376 @@
//! Declarative virtual filesystem EDSL.
//!
//! Port of ollie's virtfs pattern: declare filesystem structure statically,
//! populate dynamically via closures.
//!
//! # Example
//!
//! ```ignore
//! let fs = dir("/",
//! file("version", 0o444, read(|| Ok(b"1.0\n".to_vec()))),
//! file("ctl", 0o222, rdwr(|data| {
//! // handle command, return response
//! Ok(b"ok\n".to_vec())
//! })),
//! each("docs", || list_docs(), |name| {
//! dir(&name,
//! file("title", 0o444, read(move || get_title(&name))),
//! )
//! }),
//! );
//! ```
use std::collections::HashMap;
use std::io::{self, Result};
use std::sync::Arc;
/// File mode (Unix permissions).
pub type Mode = u32;
/// Read handler: returns file content.
pub type ReadFn = Arc<dyn Fn() -> Result<Vec<u8>> + Send + Sync>;
/// Write handler: receives data, returns Ok on success.
pub type WriteFn = Arc<dyn Fn(&[u8]) -> Result<()> + Send + Sync>;
/// Rdwr handler: write triggers computation, returns response.
pub type RdwrFn = Arc<dyn Fn(&[u8]) -> Result<Vec<u8>> + Send + Sync>;
/// List handler: returns dynamic child names.
pub type ListFn = Arc<dyn Fn() -> Result<Vec<String>> + Send + Sync>;
/// Child builder: given a name, returns the node for that child.
pub type ChildFn = Arc<dyn Fn(&str) -> Option<FsNode> + Send + Sync>;
/// A node in the virtual filesystem.
#[derive(Clone)]
pub struct FsNode {
pub name: String,
pub mode: Mode,
pub is_dir: bool,
// File operations
pub read_fn: Option<ReadFn>,
pub write_fn: Option<WriteFn>,
pub rdwr_fn: Option<RdwrFn>,
// Directory operations
pub children: HashMap<String, FsNode>, // Static children
pub list_fn: Option<ListFn>, // Dynamic child listing
pub child_fn: Option<ChildFn>, // Dynamic child builder
}
impl FsNode {
/// Create an empty node.
pub fn new(name: &str, mode: Mode, is_dir: bool) -> Self {
Self {
name: name.to_string(),
mode,
is_dir,
read_fn: None,
write_fn: None,
rdwr_fn: None,
children: HashMap::new(),
list_fn: None,
child_fn: None,
}
}
/// Walk to a child node by path.
pub fn walk(&self, path: &str) -> Option<FsNode> {
if path.is_empty() || path == "." {
return Some(self.clone());
}
let path = path.trim_start_matches('/');
let (first, rest) = match path.find('/') {
Some(i) => (&path[..i], &path[i + 1..]),
None => (path, ""),
};
// Try static children first
if let Some(child) = self.children.get(first) {
if rest.is_empty() {
return Some(child.clone());
} else {
return child.walk(rest);
}
}
// Try dynamic child builder
if let Some(ref child_fn) = self.child_fn {
if let Some(child) = child_fn(first) {
if rest.is_empty() {
return Some(child);
} else {
return child.walk(rest);
}
}
}
None
}
/// List children of this directory.
pub fn list(&self) -> Result<Vec<String>> {
if !self.is_dir {
return Err(io::Error::new(io::ErrorKind::NotADirectory, "not a directory"));
}
let mut names: Vec<String> = self.children.keys().cloned().collect();
// Add dynamic children
if let Some(ref list_fn) = self.list_fn {
let dynamic = list_fn()?;
names.extend(dynamic);
}
names.sort();
names.dedup();
Ok(names)
}
/// Read file content.
pub fn read(&self) -> Result<Vec<u8>> {
if let Some(ref f) = self.read_fn {
f()
} else {
Err(io::Error::new(io::ErrorKind::PermissionDenied, "not readable"))
}
}
/// Write to file.
pub fn write(&self, data: &[u8]) -> Result<()> {
if let Some(ref f) = self.write_fn {
f(data)
} else {
Err(io::Error::new(io::ErrorKind::PermissionDenied, "not writable"))
}
}
/// Rdwr: write then read (for command files).
pub fn rdwr(&self, data: &[u8]) -> Result<Vec<u8>> {
if let Some(ref f) = self.rdwr_fn {
f(data)
} else {
Err(io::Error::new(io::ErrorKind::PermissionDenied, "not rdwr"))
}
}
/// Check if readable.
pub fn can_read(&self) -> bool {
self.read_fn.is_some() || self.is_dir
}
/// Check if writable.
pub fn can_write(&self) -> bool {
self.write_fn.is_some() || self.rdwr_fn.is_some()
}
}
impl std::fmt::Debug for FsNode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FsNode")
.field("name", &self.name)
.field("mode", &format!("{:04o}", self.mode))
.field("is_dir", &self.is_dir)
.field("children", &self.children.keys().collect::<Vec<_>>())
.finish()
}
}
// ═══════════════════════════════════════════════════════════════════
// EDSL Constructors
// ═══════════════════════════════════════════════════════════════════
/// Create a directory node with static children.
pub fn dir(name: &str, children: Vec<FsNode>) -> FsNode {
let mut node = FsNode::new(name, 0o755, true);
for child in children {
node.children.insert(child.name.clone(), child);
}
node
}
/// Create a directory node with dynamic children added to root.
/// Unlike `each`, these children appear as siblings, not inside a named container.
pub fn dir_dynamic<L, C>(name: &str, static_children: Vec<FsNode>, list_fn: L, child_fn: C) -> FsNode
where
L: Fn() -> Result<Vec<String>> + Send + Sync + 'static,
C: Fn(&str) -> Option<FsNode> + Send + Sync + 'static,
{
let mut node = FsNode::new(name, 0o755, true);
for child in static_children {
node.children.insert(child.name.clone(), child);
}
node.list_fn = Some(Arc::new(list_fn));
node.child_fn = Some(Arc::new(child_fn));
node
}
/// Create a file node with the given options.
pub fn file(name: &str, mode: Mode, opts: Vec<FileOpt>) -> FsNode {
let mut node = FsNode::new(name, mode, false);
for opt in opts {
match opt {
FileOpt::Read(f) => node.read_fn = Some(f),
FileOpt::Write(f) => node.write_fn = Some(f),
FileOpt::Rdwr(f) => node.rdwr_fn = Some(f),
}
}
node
}
/// Create a dynamic directory: children listed by list_fn, built by child_fn.
pub fn each<L, C>(name: &str, list_fn: L, child_fn: C) -> FsNode
where
L: Fn() -> Result<Vec<String>> + Send + Sync + 'static,
C: Fn(&str) -> Option<FsNode> + Send + Sync + 'static,
{
let mut node = FsNode::new(name, 0o755, true);
node.list_fn = Some(Arc::new(list_fn));
node.child_fn = Some(Arc::new(child_fn));
node
}
// ═══════════════════════════════════════════════════════════════════
// File Options
// ═══════════════════════════════════════════════════════════════════
/// Options for configuring file nodes.
pub enum FileOpt {
Read(ReadFn),
Write(WriteFn),
Rdwr(RdwrFn),
}
/// Create a read handler.
pub fn read<F>(f: F) -> Vec<FileOpt>
where
F: Fn() -> Result<Vec<u8>> + Send + Sync + 'static,
{
vec![FileOpt::Read(Arc::new(f))]
}
/// Create a write handler.
pub fn write<F>(f: F) -> Vec<FileOpt>
where
F: Fn(&[u8]) -> Result<()> + Send + Sync + 'static,
{
vec![FileOpt::Write(Arc::new(f))]
}
/// Create a rdwr handler.
pub fn rdwr<F>(f: F) -> Vec<FileOpt>
where
F: Fn(&[u8]) -> Result<Vec<u8>> + Send + Sync + 'static,
{
vec![FileOpt::Rdwr(Arc::new(f))]
}
/// Create combined read + write handlers.
pub fn rw<R, W>(read_f: R, write_f: W) -> Vec<FileOpt>
where
R: Fn() -> Result<Vec<u8>> + Send + Sync + 'static,
W: Fn(&[u8]) -> Result<()> + Send + Sync + 'static,
{
vec![
FileOpt::Read(Arc::new(read_f)),
FileOpt::Write(Arc::new(write_f)),
]
}
// ═══════════════════════════════════════════════════════════════════
// Tests
// ═══════════════════════════════════════════════════════════════════
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_static_tree() {
let root = dir("/", vec![
file("version", 0o444, read(|| Ok(b"1.0\n".to_vec()))),
dir("subdir", vec![
file("info", 0o444, read(|| Ok(b"hello\n".to_vec()))),
]),
]);
// Walk to version
let v = root.walk("version").unwrap();
assert_eq!(v.name, "version");
assert_eq!(v.read().unwrap(), b"1.0\n");
// Walk to subdir/info
let info = root.walk("subdir/info").unwrap();
assert_eq!(info.read().unwrap(), b"hello\n");
// List root
let names = root.list().unwrap();
assert!(names.contains(&"version".to_string()));
assert!(names.contains(&"subdir".to_string()));
}
#[test]
fn test_dynamic_children() {
let items = vec!["alice".to_string(), "bob".to_string()];
let items_clone = items.clone();
let root = dir("/", vec![
each("users",
move || Ok(items_clone.clone()),
|name| {
let n = name.to_string();
let n2 = n.clone();
Some(dir(&n, vec![
file("name", 0o444, read(move || Ok(format!("{}\n", n2).into_bytes()))),
]))
},
),
]);
// List users
let users = root.walk("users").unwrap();
let names = users.list().unwrap();
assert!(names.contains(&"alice".to_string()));
assert!(names.contains(&"bob".to_string()));
// Read alice's name
let alice_name = root.walk("users/alice/name").unwrap();
assert_eq!(alice_name.read().unwrap(), b"alice\n");
}
#[test]
fn test_write() {
use std::sync::Mutex;
let data = Arc::new(Mutex::new(String::new()));
let data_clone = data.clone();
let root = dir("/", vec![
file("ctl", 0o222, write(move |d| {
*data_clone.lock().unwrap() = String::from_utf8_lossy(d).to_string();
Ok(())
})),
]);
let ctl = root.walk("ctl").unwrap();
ctl.write(b"hello").unwrap();
assert_eq!(*data.lock().unwrap(), "hello");
}
#[test]
fn test_rdwr() {
let root = dir("/", vec![
file("echo", 0o666, rdwr(|d| {
let mut response = b"got: ".to_vec();
response.extend_from_slice(d);
Ok(response)
})),
]);
let echo = root.walk("echo").unwrap();
let resp = echo.rdwr(b"test").unwrap();
assert_eq!(resp, b"got: test");
}
}