Skip to main content

isml_lsp/
cartridgepath.rs

1//! Cartridge path order, which is not in the source: from editor settings, site archives or `dw.json`.
2//! Settings win: `dw.json` is personal and usually ignored, and site archives are rarely checked out.
3
4use 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    /// The site id, or `dw.json` when that is where it came from.
15    pub label: String,
16    /// Cartridge names, leftmost — highest priority — first.
17    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
26/// Settings first, then one per site archive and `dw.json`.
27pub 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    // A stable sort keeps the settings ahead of a site of the same name, and
33    // dedup keeps the first — so naming a site in the settings corrects it.
34    found.sort_by(|a, b| a.label.cmp(&b.label));
35    found.dedup_by(|a, b| a.label == b.label);
36    found
37}
38
39/// `cartridge_path` init option: one path or an object per storefront; each a colon-joined string or an array.
40fn 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
96/// `<custom-cartridges>a:b:c</custom-cartridges>`, and the `site-id` naming it.
97fn 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
112/// Only the `cartridge` key is read. Everything else in `dw.json` is a
113/// credential, and serde drops an unknown field rather than holding it.
114fn 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
143/// Prophet allows a path in the `cartridge` array; only the name matters.
144fn 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}