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 ) {
73 if from_dir.parent() == to_dir.parent() {
74 return format!("../{to_name}");
75 }
76 }
77
78 let from = normalize(from_dir);
79 let to = normalize(to_dir);
80 let common = common_ancestor(&from, &to);
81 let ups = from
82 .strip_prefix(&common)
83 .map_or(1, |path| path.components().count());
84 let tail = to
85 .strip_prefix(&common)
86 .map(|path| path.to_string_lossy().to_string())
87 .unwrap_or_default();
88
89 format!("{}{tail}", "../".repeat(ups))
90}
91
92fn common_ancestor(a: &Path, b: &Path) -> PathBuf {
93 let mut common = PathBuf::new();
94
95 for (ac, bc) in a.components().zip(b.components()) {
96 if ac == bc {
97 common.push(ac.as_os_str());
98 } else {
99 break;
100 }
101 }
102
103 common
104}
105
106#[cfg(test)]
107mod tests {
108 use googletest::prelude::*;
109
110 use super::*;
111 use crate::{WorkspaceFile, WorkspaceIndex};
112
113 #[gtest]
114 fn common_ancestor_walks_shared_prefix() -> Result<()> {
115 let cases = [
116 ("/ws/crates/common", "/ws/crates/sentinel", "/ws/crates"),
117 ("/ws/crates/a", "/ws/crates/a", "/ws/crates/a"),
118 ("/ws/crates/a/b", "/ws/crates/a", "/ws/crates/a"),
119 ("/ws/a", "/other/b", "/"),
120 ("a/b", "a/c", "a"),
121 ];
122
123 for (a, b, expected) in cases {
124 let got = common_ancestor(Path::new(a), Path::new(b));
125
126 verify_that!(got.to_string_lossy().as_ref(), eq(expected))?;
127 }
128
129 Ok(())
130 }
131
132 #[gtest]
133 fn relative_path_siblings() -> Result<()> {
134 let from = Path::new("/ws/crates/common");
135 let to = Path::new("/ws/crates/sentinel");
136
137 verify_eq!(relative_path(from, to), "../sentinel")
138 }
139
140 #[gtest]
141 fn gather_finds_reverse_edges_from_workspace_inherited_dependencies() -> Result<()> {
142 let root = Path::new("/workspace");
143 let shared_dir = root.join("crates/shared");
144 let workspace = WorkspaceIndex::new(
145 root.to_path_buf(),
146 vec![
147 WorkspaceFile {
148 path: root.join("crates/Cargo.toml"),
149 contents: Some(
150 r#"
151[workspace]
152
153[workspace.dependencies]
154wowlab-shared = { path = "shared" }
155"#
156 .into(),
157 ),
158 },
159 WorkspaceFile {
160 path: root.join("crates/member/Cargo.toml"),
161 contents: Some(
162 r#"
163[package]
164name = "wowlab-member"
165description = "workspace member"
166
167[dependencies]
168wowlab-shared.workspace = true
169"#
170 .into(),
171 ),
172 },
173 WorkspaceFile {
174 path: shared_dir.join("Cargo.toml"),
175 contents: Some(
176 r#"
177[package]
178name = "wowlab-shared"
179description = "shared crate"
180"#
181 .into(),
182 ),
183 },
184 ],
185 );
186 let metadata = workspace.metadata(&shared_dir).or_fail()?;
187 let context = RenderCtx {
188 workspace: workspace.clone(),
189 root,
190 dir: &shared_dir,
191 rel_dir: "crates/shared",
192 metadata,
193 output_stem: "CLAUDE",
194 };
195
196 let used_by = gather(&context);
197
198 verify_that!(used_by, len(eq(1)))?;
199 verify_eq!(used_by[0].display, "member")?;
200
201 verify_eq!(used_by[0].path, "../member")
202 }
203}