1use std::collections::HashSet;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::sync::OnceLock;
5
6use crate::cartridgepath::{self, CartridgePath};
7use crate::routes::Controllers;
8
9const MAX_DEPTH: usize = 6;
10const TEMPLATE_DEPTH: usize = 10;
11const SKIPPED: [&str; 8] = [
12 "node_modules",
13 ".git",
14 "static",
15 "build",
16 "dist",
17 "coverage",
18 ".zed",
19 ".vscode",
20];
21
22#[derive(Debug, Clone)]
23pub struct Cartridge {
24 pub name: String,
26 pub root: PathBuf,
28}
29
30impl Cartridge {
31 pub fn cartridge_dir(&self) -> PathBuf {
32 self.root.join("cartridge")
33 }
34}
35
36#[derive(Debug, Default)]
37pub struct Workspace {
38 pub cartridges: Vec<Cartridge>,
39 pub module_roots: Vec<PathBuf>,
42 pub paths: Vec<CartridgePath>,
43 templates: OnceLock<Vec<String>>,
46 controllers: OnceLock<Controllers>,
47 resource_keys: OnceLock<HashSet<String>>,
48}
49
50impl Workspace {
51 pub fn scan(roots: &[PathBuf], settings: &serde_json::Value) -> Self {
53 let mut workspace = Workspace::default();
54 for root in roots {
55 workspace.visit(root, 0);
56 }
57 workspace.cartridges.sort_by(|a, b| a.name.cmp(&b.name));
58 workspace.cartridges.dedup_by(|a, b| a.root == b.root);
59 workspace.paths = cartridgepath::load(roots, settings);
60 workspace
61 }
62
63 pub fn cartridge_of(&self, file: &Path) -> Option<&Cartridge> {
64 self.cartridges
65 .iter()
66 .filter(|cartridge| file.starts_with(&cartridge.root))
67 .max_by_key(|cartridge| cartridge.root.as_os_str().len())
68 }
69
70 pub fn cartridges_from(&self, file: &Path) -> Vec<&Cartridge> {
73 let current = self.cartridge_of(file);
74 let mut ordered: Vec<&Cartridge> = current.into_iter().collect();
75 ordered.extend(self.cartridges.iter().filter(|cartridge| {
76 Some(cartridge.root.as_path()) != current.map(|c| c.root.as_path())
77 }));
78 ordered
79 }
80
81 pub fn templates(&self) -> &[String] {
84 self.templates.get_or_init(|| {
85 let mut paths: Vec<String> = Vec::new();
86 for cartridge in &self.cartridges {
87 let root = cartridge.cartridge_dir().join("templates").join("default");
88 collect_templates(&root, &root, 0, &mut paths);
89 }
90 paths.sort();
91 paths.dedup();
92 paths
93 })
94 }
95
96 pub fn resource_keys(&self) -> &HashSet<String> {
98 self.resource_keys.get_or_init(|| {
99 let mut keys = HashSet::new();
100 for cartridge in &self.cartridges {
101 let directory = cartridge
102 .cartridge_dir()
103 .join("templates")
104 .join("resources");
105 collect_keys(&directory, &mut keys);
106 }
107 keys
108 })
109 }
110
111 pub fn controllers(&self) -> &Controllers {
112 self.controllers
113 .get_or_init(|| Controllers::scan(&self.cartridges))
114 }
115
116 fn visit(&mut self, dir: &Path, depth: usize) {
117 if depth > MAX_DEPTH {
118 return;
119 }
120 let Ok(entries) = fs::read_dir(dir) else {
121 return;
122 };
123 for entry in entries.flatten() {
124 if !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
125 continue;
126 }
127 let path = entry.path();
128 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
129 continue;
130 };
131 if SKIPPED.contains(&name) || name.starts_with('.') {
132 continue;
133 }
134 if name == "cartridge" {
135 if let Some(cartridge) = cartridge_at(&path) {
136 self.cartridges.push(cartridge);
137 }
138 continue;
139 }
140 if name == "modules" && dir.file_name().is_some_and(|parent| parent == "cartridges") {
141 self.module_roots.push(path.clone());
142 continue;
143 }
144 self.visit(&path, depth + 1);
145 }
146 }
147}
148
149fn collect_keys(directory: &Path, into: &mut HashSet<String>) {
151 let Ok(entries) = fs::read_dir(directory) else {
152 return;
153 };
154 for entry in entries.flatten() {
155 let path = entry.path();
156 if path
157 .extension()
158 .is_none_or(|extension| extension != "properties")
159 {
160 continue;
161 }
162 let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
163 continue;
164 };
165 if stem.contains('_') {
166 continue;
167 }
168 let Ok(text) = fs::read_to_string(&path) else {
169 continue;
170 };
171 for line in text.lines() {
172 let trimmed = line.trim_start();
173 if trimmed.starts_with('#') || trimmed.starts_with('!') {
174 continue;
175 }
176 if let Some((key, _)) = trimmed.split_once('=') {
177 let key = key.trim();
178 if !key.is_empty() {
179 into.insert(key.to_string());
180 }
181 }
182 }
183 }
184}
185
186fn collect_templates(dir: &Path, root: &Path, depth: usize, into: &mut Vec<String>) {
187 if depth > TEMPLATE_DEPTH {
188 return;
189 }
190 let Ok(entries) = fs::read_dir(dir) else {
191 return;
192 };
193 for entry in entries.flatten() {
194 let path = entry.path();
195 if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
196 collect_templates(&path, root, depth + 1, into);
197 continue;
198 }
199 if path.extension().is_none_or(|extension| extension != "isml") {
200 continue;
201 }
202 if let Some(name) = template_name(&path, root) {
203 into.push(name);
204 }
205 }
206}
207
208fn template_name(file: &Path, root: &Path) -> Option<String> {
209 let relative = file.strip_prefix(root).ok()?.with_extension("");
210 let joined = relative
211 .components()
212 .map(|component| component.as_os_str().to_string_lossy().into_owned())
213 .collect::<Vec<_>>()
214 .join("/");
215 (!joined.is_empty()).then_some(joined)
216}
217
218fn cartridge_at(cartridge_dir: &Path) -> Option<Cartridge> {
219 let root = cartridge_dir.parent()?.to_path_buf();
220 let name = root.file_name()?.to_str()?.to_string();
221 Some(Cartridge { name, root })
222}