1use wowlab_common::markdown::{Table, code, link};
2use wowlab_fs::path::{Path, PathBuf};
3
4use crate::{WorkspaceIndex, infra::metadata};
5
6#[derive(Debug)]
7struct Entry {
8 rel_path: String,
9 description: String,
10 kind: String,
11}
12
13#[must_use]
15pub fn build(
16 workspace: &WorkspaceIndex,
17 current_dir: &Path,
18 file: &str,
19 patterns: &[String],
20 show_kind: bool,
21) -> String {
22 let entries = collect(workspace, file, patterns, show_kind);
23
24 if entries.is_empty() {
25 return String::new();
26 }
27
28 render_table(workspace, current_dir, &entries, file, show_kind)
29}
30
31fn collect(
32 workspace: &WorkspaceIndex,
33 file: &str,
34 patterns: &[String],
35 show_kind: bool,
36) -> Vec<Entry> {
37 let mut entries = Vec::new();
38
39 for pattern in patterns {
40 entries.extend(collect_pattern(workspace, file, pattern, show_kind));
41 }
42
43 entries
44}
45
46fn collect_pattern(
47 workspace: &WorkspaceIndex,
48 file: &str,
49 pattern: &str,
50 show_kind: bool,
51) -> Vec<Entry> {
52 if pattern.contains('*') {
53 collect_glob(workspace, file, pattern, show_kind)
54 } else {
55 collect_dir_children(workspace, file, pattern, show_kind)
56 }
57}
58
59fn resolve_kind(workspace: &WorkspaceIndex, dir: &Path, show_kind: bool) -> String {
60 if show_kind {
61 metadata::load(workspace, dir).kind.as_str().to_string()
62 } else {
63 String::new()
64 }
65}
66
67fn collect_dir_children(
68 workspace: &WorkspaceIndex,
69 file: &str,
70 dir_path: &str,
71 show_kind: bool,
72) -> Vec<Entry> {
73 let root = workspace.root();
74 let abs = root.join(dir_path);
75
76 if !workspace.is_dir(&abs) {
77 if has_file(workspace, &abs, file) {
78 let meta = metadata::load(workspace, &abs);
79 let kind = resolve_kind(workspace, &abs, show_kind);
80
81 return vec![Entry {
82 rel_path: dir_path.to_string(),
83 description: meta.description.clone().unwrap_or_default(),
84 kind,
85 }];
86 }
87
88 return Vec::new();
89 }
90
91 let mut children: Vec<Entry> = workspace
92 .child_dirs(&abs)
93 .filter_map(|path| {
94 let name = path.file_name()?.to_string_lossy().to_string();
95
96 if !has_file(workspace, path, file) {
97 return None;
98 }
99
100 let meta = metadata::load(workspace, path);
101 let kind = resolve_kind(workspace, path, show_kind);
102
103 Some(Entry {
104 rel_path: format!("{dir_path}/{name}"),
105 description: meta.description.clone().unwrap_or_default(),
106 kind,
107 })
108 })
109 .collect();
110
111 if children.is_empty() {
112 if has_file(workspace, &abs, file) {
113 let meta = metadata::load(workspace, &abs);
114 let kind = resolve_kind(workspace, &abs, show_kind);
115
116 return vec![Entry {
117 rel_path: dir_path.to_string(),
118 description: meta.description.clone().unwrap_or_default(),
119 kind,
120 }];
121 }
122
123 return Vec::new();
124 }
125
126 children.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
127
128 children
129}
130
131fn collect_glob(
132 workspace: &WorkspaceIndex,
133 file: &str,
134 pattern: &str,
135 show_kind: bool,
136) -> Vec<Entry> {
137 let root = workspace.root();
138 let (parent_str, glob_part) = match pattern.rfind('/') {
139 Some(i) => (&pattern[..i], &pattern[i + 1..]),
141 None => ("", pattern),
142 };
143
144 let parent_abs = if parent_str.is_empty() {
145 root.to_path_buf()
146 } else {
147 root.join(parent_str)
148 };
149
150 if !workspace.is_dir(&parent_abs) {
151 return Vec::new();
152 }
153
154 let mut entries: Vec<Entry> = workspace
155 .child_dirs(&parent_abs)
156 .filter_map(|path| {
157 let name = path.file_name()?.to_string_lossy().to_string();
158
159 if !glob_match(glob_part, &name) {
160 return None;
161 }
162
163 if !has_file(workspace, path, file) {
164 return None;
165 }
166
167 let meta = metadata::load(workspace, path);
168 let kind = resolve_kind(workspace, path, show_kind);
169 let rel_path = if parent_str.is_empty() {
170 name
171 } else {
172 format!("{parent_str}/{name}")
173 };
174
175 Some(Entry {
176 rel_path,
177 description: meta.description.clone().unwrap_or_default(),
178 kind,
179 })
180 })
181 .collect();
182
183 entries.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
184
185 entries
186}
187
188fn glob_match(pattern: &str, name: &str) -> bool {
189 glob_match::glob_match(pattern, name)
190}
191
192fn has_file(workspace: &WorkspaceIndex, path: &Path, file: &str) -> bool {
193 workspace.contains(&path.join(format!("{file}.in"))) || workspace.contains(&path.join(file))
194}
195
196fn render_table(
197 workspace: &WorkspaceIndex,
198 current_dir: &Path,
199 entries: &[Entry],
200 file: &str,
201 show_kind: bool,
202) -> String {
203 if show_kind {
204 Table::new()
205 .headers(["Path", "What it is", "Kind"])
206 .rows(entries.iter().map(|e| {
207 [
208 link(
209 code(&e.rel_path),
210 relative_link(workspace, current_dir, e, file),
211 ),
212 e.description.clone(),
213 e.kind.clone(),
214 ]
215 }))
216 .build_markdown()
217 } else {
218 Table::new()
219 .headers(["Path", "What it is"])
220 .rows(entries.iter().map(|e| {
221 [
222 link(
223 code(&e.rel_path),
224 relative_link(workspace, current_dir, e, file),
225 ),
226 e.description.clone(),
227 ]
228 }))
229 .build_markdown()
230 }
231}
232
233fn relative_link(
234 workspace: &WorkspaceIndex,
235 current_dir: &Path,
236 entry: &Entry,
237 file: &str,
238) -> String {
239 let target = workspace.root().join(&entry.rel_path).join(file);
240
241 relative_path(current_dir, &target)
242}
243
244fn relative_path(from: &Path, to: &Path) -> String {
245 let from = crate::workspace::normalize(from);
246 let to = crate::workspace::normalize(to);
247 let common = from
248 .components()
249 .zip(to.components())
250 .take_while(|(left, right)| left == right)
251 .count();
252 let mut relative = PathBuf::new();
253
254 for _ in from.components().skip(common) {
255 relative.push("..");
256 }
257
258 for component in to.components().skip(common) {
259 relative.push(component.as_os_str());
260 }
261
262 relative.to_string_lossy().into_owned()
263}
264
265#[cfg(test)]
266mod tests {
267 use googletest::prelude::*;
268
269 use super::*;
270 use crate::WorkspaceFile;
271
272 fn workspace(files: &[(&str, &str)]) -> WorkspaceIndex {
273 let root = Path::new("/ws");
274 let files = files
275 .iter()
276 .map(|(path, contents)| WorkspaceFile {
277 path: root.join(path),
278 contents: Some(Box::from(*contents)),
279 })
280 .collect();
281
282 WorkspaceIndex::new(root.to_path_buf(), files)
283 }
284
285 fn resolve_kind(files: &[(&str, &str)], show_kind: bool) -> String {
286 super::resolve_kind(&workspace(files), Path::new("/ws"), show_kind)
287 }
288
289 #[gtest]
290 fn glob_match_patterns() -> Result<()> {
291 verify_that!(glob_match("engine-*", "engine-world"), eq(true))?;
292 verify_that!(glob_match("engine-*", "engine"), eq(false))?;
293 verify_that!(glob_match("engine", "engine"), eq(true))?;
294 verify_that!(glob_match("engine", "engine-world"), eq(false))?;
295
296 Ok(())
297 }
298
299 #[gtest]
300 fn render_table_format() -> Result<()> {
301 let root = Path::new("/ws");
302 let workspace = workspace(&[]);
303 let entries = vec![
304 Entry {
305 rel_path: "apps/portal".to_string(),
306 description: "The website".to_string(),
307 kind: String::new(),
308 },
309 Entry {
310 rel_path: "apps/addon".to_string(),
311 description: "In-game addon".to_string(),
312 kind: String::new(),
313 },
314 ];
315 let table = render_table(&workspace, root, &entries, "README.md", false);
316
317 verify_that!(table, contains_substring("Path"))?;
318 verify_that!(table, contains_substring("What it is"))?;
319 verify_that!(
320 table,
321 contains_substring("[`apps/portal`](apps/portal/README.md)")
322 )?;
323 verify_that!(table, contains_substring("The website"))?;
324 verify_that!(
325 table,
326 contains_substring("[`apps/addon`](apps/addon/README.md)")
327 )?;
328 verify_that!(table, contains_substring("In-game addon"))?;
329 verify_that!(table, not(contains_substring("Kind")))?;
330
331 Ok(())
332 }
333
334 #[gtest]
335 fn render_table_with_kind() -> Result<()> {
336 let root = Path::new("/ws");
337 let workspace = workspace(&[]);
338 let entries = vec![
339 Entry {
340 rel_path: "crates/engine".to_string(),
341 description: "Core engine".to_string(),
342 kind: "crate".to_string(),
343 },
344 Entry {
345 rel_path: "apps/portal".to_string(),
346 description: "The website".to_string(),
347 kind: "package".to_string(),
348 },
349 Entry {
350 rel_path: "addons/tracker".to_string(),
351 description: "Raid tracker".to_string(),
352 kind: "addon".to_string(),
353 },
354 ];
355 let table = render_table(&workspace, root, &entries, "README.md", true);
356
357 verify_that!(table, contains_substring("Path"))?;
358 verify_that!(table, contains_substring("What it is"))?;
359 verify_that!(table, contains_substring("Kind"))?;
360 verify_that!(
361 table,
362 contains_substring("[`crates/engine`](crates/engine/README.md)")
363 )?;
364 verify_that!(table, contains_substring("crate"))?;
365 verify_that!(table, contains_substring("package"))?;
366 verify_that!(table, contains_substring("addon"))?;
367
368 Ok(())
369 }
370
371 #[gtest]
372 fn render_table_links_from_current_document_directory() -> Result<()> {
373 let root = Path::new("/ws");
374 let workspace = workspace(&[]);
375 let entries = vec![Entry {
376 rel_path: "crates/engine-domain".to_string(),
377 description: "Core domain types".to_string(),
378 kind: String::new(),
379 }];
380
381 let table = render_table(
382 &workspace,
383 &root.join("crates/engine"),
384 &entries,
385 "README.md",
386 false,
387 );
388
389 verify_that!(
390 table,
391 contains_substring("[`crates/engine-domain`](../engine-domain/README.md)")
392 )?;
393
394 Ok(())
395 }
396
397 #[gtest]
398 fn resolve_kind_crate() -> Result<()> {
399 verify_that!(
400 resolve_kind(
401 &[(
402 "Cargo.toml",
403 "[package]\nname = \"test\"\ndescription = \"\"",
404 )],
405 true,
406 ),
407 eq("crate")
408 )
409 }
410
411 #[gtest]
412 fn resolve_kind_package() -> Result<()> {
413 verify_that!(resolve_kind(&[("package.json", "{}")], true), eq("package"))
414 }
415
416 #[gtest]
417 fn resolve_kind_directory() -> Result<()> {
418 verify_that!(resolve_kind(&[], true), eq("directory"))
419 }
420
421 #[gtest]
422 fn resolve_kind_skipped_when_false() -> Result<()> {
423 verify_that!(
424 resolve_kind(
425 &[(
426 "Cargo.toml",
427 "[package]\nname = \"test\"\ndescription = \"\"",
428 )],
429 false,
430 ),
431 eq("")
432 )
433 }
434}