isml_lsp/
cartridgepath.rs1use std::fs;
5use std::path::{Path, PathBuf};
6
7use serde_json::Value;
8
9const MAX_DEPTH: usize = 8;
10const SKIPPED: [&str; 6] = ["node_modules", ".git", "static", "build", "dist", "target"];
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct CartridgePath {
14 pub label: String,
16 pub order: Vec<String>,
18}
19
20impl CartridgePath {
21 pub fn rank(&self, cartridge: &str) -> Option<usize> {
22 self.order.iter().position(|name| name == cartridge)
23 }
24}
25
26pub fn load(roots: &[PathBuf], settings: &Value) -> Vec<CartridgePath> {
28 let mut found = from_settings(settings);
29 for root in roots {
30 visit(root, 0, &mut found);
31 }
32 found.sort_by(|a, b| a.label.cmp(&b.label));
35 found.dedup_by(|a, b| a.label == b.label);
36 found
37}
38
39fn from_settings(settings: &Value) -> Vec<CartridgePath> {
41 let Some(declared) = settings.get("cartridge_path") else {
42 return Vec::new();
43 };
44 if let Some(sites) = declared.as_object() {
45 return sites
46 .iter()
47 .filter_map(|(label, value)| named(label, value))
48 .collect();
49 }
50 named("settings", declared).into_iter().collect()
51}
52
53fn named(label: &str, value: &Value) -> Option<CartridgePath> {
54 let order = match value {
55 Value::String(text) => split_path(text),
56 Value::Array(names) => names
57 .iter()
58 .filter_map(Value::as_str)
59 .map(leaf_name)
60 .filter(|name| !name.is_empty())
61 .collect(),
62 _ => Vec::new(),
63 };
64 (!order.is_empty()).then(|| CartridgePath {
65 label: label.to_string(),
66 order,
67 })
68}
69
70fn visit(dir: &Path, depth: usize, into: &mut Vec<CartridgePath>) {
71 if depth > MAX_DEPTH {
72 return;
73 }
74 let Ok(entries) = fs::read_dir(dir) else {
75 return;
76 };
77 for entry in entries.flatten() {
78 let path = entry.path();
79 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
80 continue;
81 };
82 if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
83 if !SKIPPED.contains(&name) && !name.starts_with('.') {
84 visit(&path, depth + 1, into);
85 }
86 continue;
87 }
88 match name {
89 "site.xml" => into.extend(from_site_archive(&path)),
90 "dw.json" => into.extend(from_dw_json(&path)),
91 _ => {}
92 }
93 }
94}
95
96fn from_site_archive(file: &Path) -> Option<CartridgePath> {
98 let text = fs::read_to_string(file).ok()?;
99 let document = roxmltree::Document::parse(&text).ok()?;
100 let root = document.root_element();
101 let label = root
102 .attribute("site-id")
103 .map(str::to_string)
104 .or_else(|| directory_name(file))?;
105 let node = root
106 .descendants()
107 .find(|node| node.tag_name().name() == "custom-cartridges")?;
108 let order = split_path(node.text()?);
109 (!order.is_empty()).then_some(CartridgePath { label, order })
110}
111
112fn from_dw_json(file: &Path) -> Option<CartridgePath> {
115 #[derive(serde::Deserialize)]
116 struct Relevant {
117 cartridge: Option<Vec<String>>,
118 }
119
120 let text = fs::read_to_string(file).ok()?;
121 let relevant: Relevant = serde_json::from_str(&text).ok()?;
122 let order: Vec<String> = relevant
123 .cartridge?
124 .iter()
125 .map(|name| leaf_name(name))
126 .filter(|name| !name.is_empty())
127 .collect();
128 (!order.is_empty()).then_some(CartridgePath {
129 label: "dw.json".to_string(),
130 order,
131 })
132}
133
134fn split_path(value: &str) -> Vec<String> {
135 value
136 .split(':')
137 .map(str::trim)
138 .filter(|name| !name.is_empty())
139 .map(str::to_string)
140 .collect()
141}
142
143fn leaf_name(value: &str) -> String {
145 value
146 .trim()
147 .trim_end_matches(['/', '\\'])
148 .rsplit(['/', '\\'])
149 .next()
150 .unwrap_or_default()
151 .to_string()
152}
153
154fn directory_name(file: &Path) -> Option<String> {
155 Some(file.parent()?.file_name()?.to_str()?.to_string())
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161
162 const SITE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
163<site xmlns="http://www.demandware.com/xml/impex/site/2007-4-31" site-id="storefront">
164 <currency>EUR</currency>
165 <custom-cartridges>int_search:app_brand:app_storefront_base</custom-cartridges>
166</site>
167"#;
168
169 fn scratch(name: &str) -> PathBuf {
170 let directory = std::env::temp_dir().join(name);
171 let _ = fs::remove_dir_all(&directory);
172 fs::create_dir_all(&directory).unwrap();
173 directory
174 }
175
176 #[test]
177 fn reads_a_path_per_storefront_from_the_settings() {
178 let settings = serde_json::json!({
179 "cartridge_path": {
180 "storefront_a": "app_brand:app_storefront_base",
181 "storefront_b": ["int_search", "app_storefront_base"],
182 }
183 });
184 let found = load(&[], &settings);
185 assert_eq!(found.len(), 2);
186 assert_eq!(found[0].label, "storefront_a");
187 assert_eq!(found[0].order, ["app_brand", "app_storefront_base"]);
188 assert_eq!(found[1].order, ["int_search", "app_storefront_base"]);
189 }
190
191 #[test]
192 fn reads_a_single_unnamed_path_from_the_settings() {
193 let settings = serde_json::json!({ "cartridge_path": "app_brand:app_storefront_base" });
194 let found = load(&[], &settings);
195 assert_eq!(found.len(), 1);
196 assert_eq!(found[0].label, "settings");
197 }
198
199 #[test]
200 fn lets_the_settings_correct_a_site_of_the_same_name() {
201 let directory = scratch("isml-lsp-settings-wins");
202 fs::write(directory.join("site.xml"), SITE).unwrap();
203 let settings = serde_json::json!({ "cartridge_path": { "storefront": ["app_brand"] } });
204 let found = load(&[directory], &settings);
205 assert_eq!(found.len(), 1);
206 assert_eq!(found[0].order, ["app_brand"]);
207 }
208
209 #[test]
210 fn ignores_settings_that_declare_nothing() {
211 assert!(load(&[], &Value::Null).is_empty());
212 assert!(load(&[], &serde_json::json!({ "cartridge_path": [] })).is_empty());
213 }
214
215 #[test]
216 fn reads_the_ordered_path_of_each_site() {
217 let directory = scratch("isml-lsp-site-path");
218 fs::write(directory.join("site.xml"), SITE).unwrap();
219 let found = load(&[directory], &Value::Null);
220 assert_eq!(found.len(), 1);
221 assert_eq!(found[0].label, "storefront");
222 assert_eq!(
223 found[0].order,
224 ["int_search", "app_brand", "app_storefront_base"]
225 );
226 }
227
228 #[test]
229 fn ranks_by_position_and_ignores_a_cartridge_outside_the_path() {
230 let path = CartridgePath {
231 label: "storefront".into(),
232 order: vec!["a".into(), "b".into()],
233 };
234 assert_eq!(path.rank("a"), Some(0));
235 assert_eq!(path.rank("b"), Some(1));
236 assert_eq!(path.rank("c"), None);
237 }
238
239 #[test]
240 fn reads_the_cartridge_array_of_dw_json_and_nothing_else() {
241 let directory = scratch("isml-lsp-dw-path");
242 fs::write(
243 directory.join("dw.json"),
244 r#"{"hostname":"x","username":"u","password":"p",
245 "cartridge":["app_brand","app_storefront_base"]}"#,
246 )
247 .unwrap();
248 let found = load(&[directory], &Value::Null);
249 assert_eq!(found.len(), 1);
250 assert_eq!(found[0].label, "dw.json");
251 assert_eq!(found[0].order, ["app_brand", "app_storefront_base"]);
252 }
253
254 #[test]
255 fn says_nothing_when_dw_json_carries_no_cartridge_array() {
256 let directory = scratch("isml-lsp-dw-bare");
257 fs::write(
258 directory.join("dw.json"),
259 r#"{"hostname":"x","password":"p"}"#,
260 )
261 .unwrap();
262 assert!(load(&[directory], &Value::Null).is_empty());
263 }
264}