Skip to main content

wowlab_docgen_cli/context/
used_by.rs

1use serde::Serialize;
2use wowlab_fs::path::{Path, PathBuf};
3
4use crate::{
5    HasName, RenderCtx, display_name, infra::metadata, sort_by_name, workspace::normalize,
6};
7
8/// A crate that depends on the current package.
9#[derive(Debug, Serialize)]
10// #t(rust_similar_structs) reverse-dependency rows own relative paths while dependency rows own manifest paths
11pub(super) struct UsedByEntry {
12    pub display: String,
13    pub path: String,
14    pub description: String,
15}
16
17impl HasName for UsedByEntry {
18    fn name(&self) -> &str {
19        &self.display
20    }
21}
22
23/// Find workspace crates that depend on the current package via path dependencies.
24pub(super) fn gather(ctx: &RenderCtx<'_>) -> Vec<UsedByEntry> {
25    let crates_dir = ctx.root.join("crates");
26
27    let current_dir = normalize(ctx.dir);
28
29    let mut entries: Vec<UsedByEntry> = ctx
30        .workspace
31        .child_dirs(&crates_dir)
32        .filter_map(|crate_dir| {
33            let crate_metadata = metadata::load(&ctx.workspace, crate_dir);
34
35            let depends_on_us = crate_metadata
36                .workspace_deps
37                .values()
38                .any(|dependency| normalize(&crate_dir.join(&dependency.path)) == current_dir);
39
40            if !depends_on_us {
41                return None;
42            }
43
44            let name = crate_dir.file_name()?.to_string_lossy().to_string();
45
46            if name == ctx.dir.file_name()?.to_string_lossy() {
47                return None;
48            }
49
50            let display = display_name(&name);
51            let description = crate_metadata.description.clone().unwrap_or_default();
52
53            let rel = relative_path(ctx.dir, crate_dir);
54
55            Some(UsedByEntry {
56                display,
57                path: rel,
58                description,
59            })
60        })
61        .collect();
62
63    sort_by_name(&mut entries);
64
65    entries
66}
67
68fn relative_path(from_dir: &Path, to_dir: &Path) -> String {
69    if let (Some(_), Some(to_name)) = (
70        from_dir.file_name().map(|n| n.to_string_lossy()),
71        to_dir.file_name().map(|n| n.to_string_lossy()),
72    ) && from_dir.parent() == to_dir.parent()
73    {
74        return format!("../{to_name}");
75    }
76
77    let from = normalize(from_dir);
78    let to = normalize(to_dir);
79    let common = common_ancestor(&from, &to);
80    let ups = from
81        .strip_prefix(&common)
82        .map_or(1, |path| path.components().count());
83    let tail = to
84        .strip_prefix(&common)
85        .map(|path| path.to_string_lossy().to_string())
86        .unwrap_or_default();
87
88    format!("{}{tail}", "../".repeat(ups))
89}
90
91fn common_ancestor(a: &Path, b: &Path) -> PathBuf {
92    let mut common = PathBuf::new();
93
94    for (ac, bc) in a.components().zip(b.components()) {
95        if ac == bc {
96            common.push(ac.as_os_str());
97        } else {
98            break;
99        }
100    }
101
102    common
103}
104
105#[cfg(test)]
106mod tests {
107    use googletest::prelude::*;
108
109    use super::*;
110    use crate::{WorkspaceFile, WorkspaceIndex};
111
112    #[gtest]
113    fn common_ancestor_walks_shared_prefix() -> Result<()> {
114        let cases = [
115            ("/ws/crates/common", "/ws/crates/sentinel", "/ws/crates"),
116            ("/ws/crates/a", "/ws/crates/a", "/ws/crates/a"),
117            ("/ws/crates/a/b", "/ws/crates/a", "/ws/crates/a"),
118            ("/ws/a", "/other/b", "/"),
119            ("a/b", "a/c", "a"),
120        ];
121
122        for (a, b, expected) in cases {
123            let got = common_ancestor(Path::new(a), Path::new(b));
124
125            verify_that!(got.to_string_lossy().as_ref(), eq(expected))?;
126        }
127
128        Ok(())
129    }
130
131    #[gtest]
132    fn relative_path_siblings() -> Result<()> {
133        let from = Path::new("/ws/crates/common");
134        let to = Path::new("/ws/crates/sentinel");
135
136        verify_eq!(relative_path(from, to), "../sentinel")
137    }
138
139    #[gtest]
140    fn gather_finds_reverse_edges_from_workspace_inherited_dependencies() -> Result<()> {
141        let root = Path::new("/workspace");
142        let shared_dir = root.join("crates/shared");
143        let workspace = WorkspaceIndex::new(
144            root.to_path_buf(),
145            vec![
146                WorkspaceFile {
147                    path: root.join("crates/Cargo.toml"),
148                    contents: Some(
149                        r#"
150[workspace]
151
152[workspace.dependencies]
153wowlab-shared = { path = "shared" }
154"#
155                        .into(),
156                    ),
157                },
158                WorkspaceFile {
159                    path: root.join("crates/member/Cargo.toml"),
160                    contents: Some(
161                        r#"
162[package]
163name = "wowlab-member"
164description = "workspace member"
165
166[dependencies]
167wowlab-shared.workspace = true
168"#
169                        .into(),
170                    ),
171                },
172                WorkspaceFile {
173                    path: shared_dir.join("Cargo.toml"),
174                    contents: Some(
175                        r#"
176[package]
177name = "wowlab-shared"
178description = "shared crate"
179"#
180                        .into(),
181                    ),
182                },
183            ],
184        );
185        let metadata = workspace.metadata(&shared_dir).or_fail()?;
186        let context = RenderCtx {
187            workspace: workspace.clone(),
188            root,
189            dir: &shared_dir,
190            rel_dir: "crates/shared",
191            metadata,
192            output_stem: "CLAUDE",
193        };
194
195        let used_by = gather(&context);
196
197        verify_that!(used_by, len(eq(1)))?;
198        verify_eq!(used_by[0].display, "member")?;
199
200        verify_eq!(used_by[0].path, "../member")
201    }
202}