1use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15
16use anyhow::{Context, Result};
17
18pub trait ProjectFiles {
20 fn read(&self, path: &str) -> Result<Vec<u8>>;
26
27 fn list(&self, prefix: &str) -> Vec<String>;
32
33 fn exists(&self, path: &str) -> bool {
35 self.read(path).is_ok()
36 }
37
38 fn root(&self) -> Option<&Path> {
42 None
43 }
44}
45
46pub struct DiskFiles {
48 root: PathBuf,
49}
50
51impl DiskFiles {
52 pub fn new(root: impl Into<PathBuf>) -> Self {
54 Self { root: root.into() }
55 }
56}
57
58impl ProjectFiles for DiskFiles {
59 fn read(&self, path: &str) -> Result<Vec<u8>> {
60 let full = self.root.join(path);
61 std::fs::read(&full).with_context(|| format!("failed to read {}", full.display()))
62 }
63
64 fn list(&self, prefix: &str) -> Vec<String> {
65 let base = if prefix.is_empty() {
66 self.root.clone()
67 } else {
68 self.root.join(prefix)
69 };
70 let mut out = Vec::new();
71 if base.is_dir() {
72 walk(&base, &self.root, &mut out);
73 }
74 out.sort();
75 out
76 }
77
78 fn root(&self) -> Option<&Path> {
79 Some(&self.root)
80 }
81}
82
83pub struct MemoryFiles {
85 map: HashMap<String, Vec<u8>>,
86}
87
88impl MemoryFiles {
89 pub fn new(map: HashMap<String, Vec<u8>>) -> Self {
91 Self { map }
92 }
93}
94
95impl From<HashMap<String, Vec<u8>>> for MemoryFiles {
96 fn from(map: HashMap<String, Vec<u8>>) -> Self {
97 Self::new(map)
98 }
99}
100
101impl ProjectFiles for MemoryFiles {
102 fn read(&self, path: &str) -> Result<Vec<u8>> {
103 self.map
104 .get(path)
105 .cloned()
106 .with_context(|| format!("no such file '{path}'"))
107 }
108
109 fn list(&self, prefix: &str) -> Vec<String> {
110 let mut out: Vec<String> = self
111 .map
112 .keys()
113 .filter(|k| {
114 prefix.is_empty()
115 || k.as_str() == prefix
116 || k.strip_prefix(prefix).is_some_and(|rest| rest.starts_with('/'))
117 })
118 .cloned()
119 .collect();
120 out.sort();
121 out
122 }
123}
124
125pub fn join_path(base: &str, rel: &str) -> String {
129 let rel = rel.strip_prefix("./").unwrap_or(rel);
130 let rel = rel.strip_suffix('/').unwrap_or(rel);
131 if base.is_empty() {
132 rel.to_string()
133 } else if rel.is_empty() {
134 base.to_string()
135 } else {
136 format!("{base}/{rel}")
137 }
138}
139
140fn walk(dir: &Path, root: &Path, out: &mut Vec<String>) {
143 let Ok(entries) = std::fs::read_dir(dir) else {
144 return;
145 };
146 for entry in entries.flatten() {
147 let path = entry.path();
148 if path.is_dir() {
149 walk(&path, root, out);
150 } else if path.is_file() {
151 if let Ok(rel) = path.strip_prefix(root) {
152 let posix = rel
153 .components()
154 .map(|c| c.as_os_str().to_string_lossy().into_owned())
155 .collect::<Vec<_>>()
156 .join("/");
157 out.push(posix);
158 }
159 }
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 fn mem() -> MemoryFiles {
168 MemoryFiles::new(HashMap::from([
169 ("data/maps/Town/map.tmx.json".to_string(), b"{}".to_vec()),
170 ("data/maps/Town/objects.json".to_string(), b"{}".to_vec()),
171 ("data/maps/Field/map.tmx.json".to_string(), b"{}".to_vec()),
172 ("data/rules.ron".to_string(), b"()".to_vec()),
173 (".dotzuki-editor.json".to_string(), b"{}".to_vec()),
174 ]))
175 }
176
177 #[test]
178 fn memory_list_is_recursive_sorted_and_prefix_bounded() {
179 let files = mem();
180 assert_eq!(
181 files.list("data/maps"),
182 vec![
183 "data/maps/Field/map.tmx.json".to_string(),
184 "data/maps/Town/map.tmx.json".to_string(),
185 "data/maps/Town/objects.json".to_string(),
186 ]
187 );
188 assert_eq!(files.list("data/mapss"), Vec::<String>::new());
190 assert_eq!(files.list("").len(), 5);
191 }
192
193 #[test]
194 fn memory_read_and_exists() {
195 let files = mem();
196 assert_eq!(files.read("data/rules.ron").unwrap(), b"()".to_vec());
197 assert!(files.read("nope.json").is_err());
198 assert!(files.exists("data/rules.ron"));
199 assert!(!files.exists("nope.json"));
200 }
201
202 #[test]
203 fn join_path_normalizes() {
204 assert_eq!(join_path("", "data"), "data");
205 assert_eq!(join_path("", "./data"), "data");
206 assert_eq!(join_path("data", "maps"), "data/maps");
207 assert_eq!(join_path("data", "./maps"), "data/maps");
208 assert_eq!(join_path("data", ""), "data");
209 }
210}