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)
117 .is_some_and(|rest| rest.starts_with('/'))
118 })
119 .cloned()
120 .collect();
121 out.sort();
122 out
123 }
124}
125
126pub fn join_path(base: &str, rel: &str) -> String {
130 let rel = rel.strip_prefix("./").unwrap_or(rel);
131 let rel = rel.strip_suffix('/').unwrap_or(rel);
132 if base.is_empty() {
133 rel.to_string()
134 } else if rel.is_empty() {
135 base.to_string()
136 } else {
137 format!("{base}/{rel}")
138 }
139}
140
141fn walk(dir: &Path, root: &Path, out: &mut Vec<String>) {
144 let Ok(entries) = std::fs::read_dir(dir) else {
145 return;
146 };
147 for entry in entries.flatten() {
148 let path = entry.path();
149 if path.is_dir() {
150 walk(&path, root, out);
151 } else if path.is_file() {
152 if let Ok(rel) = path.strip_prefix(root) {
153 let posix = rel
154 .components()
155 .map(|c| c.as_os_str().to_string_lossy().into_owned())
156 .collect::<Vec<_>>()
157 .join("/");
158 out.push(posix);
159 }
160 }
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 fn mem() -> MemoryFiles {
169 MemoryFiles::new(HashMap::from([
170 ("data/maps/Town/map.tmx.json".to_string(), b"{}".to_vec()),
171 ("data/maps/Town/objects.json".to_string(), b"{}".to_vec()),
172 ("data/maps/Field/map.tmx.json".to_string(), b"{}".to_vec()),
173 ("data/rules.ron".to_string(), b"()".to_vec()),
174 (".dotzuki-editor.json".to_string(), b"{}".to_vec()),
175 ]))
176 }
177
178 #[test]
179 fn memory_list_is_recursive_sorted_and_prefix_bounded() {
180 let files = mem();
181 assert_eq!(
182 files.list("data/maps"),
183 vec![
184 "data/maps/Field/map.tmx.json".to_string(),
185 "data/maps/Town/map.tmx.json".to_string(),
186 "data/maps/Town/objects.json".to_string(),
187 ]
188 );
189 assert_eq!(files.list("data/mapss"), Vec::<String>::new());
191 assert_eq!(files.list("").len(), 5);
192 }
193
194 #[test]
195 fn memory_read_and_exists() {
196 let files = mem();
197 assert_eq!(files.read("data/rules.ron").unwrap(), b"()".to_vec());
198 assert!(files.read("nope.json").is_err());
199 assert!(files.exists("data/rules.ron"));
200 assert!(!files.exists("nope.json"));
201 }
202
203 #[test]
204 fn join_path_normalizes() {
205 assert_eq!(join_path("", "data"), "data");
206 assert_eq!(join_path("", "./data"), "data");
207 assert_eq!(join_path("data", "maps"), "data/maps");
208 assert_eq!(join_path("data", "./maps"), "data/maps");
209 assert_eq!(join_path("data", ""), "data");
210 }
211}