Skip to main content

wowlab_docgen_cli/infra/
index_of.rs

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/// Scan directories matching `patterns` for those containing `file` and render an index table.
14#[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        // BOUNDS: `rfind` returns a valid char boundary; '/' is ASCII (1 byte) so i+1 is valid.
140        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}