wowlab_docgen_cli/context/
used_by.rs1use 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#[derive(Debug, Serialize)]
10pub(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
23pub(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}