ferritin_common/sources/
local.rs1use super::CrateProvenance;
2use crate::RustdocData;
3use crate::crate_name::CrateName;
4use crate::navigator::CrateInfo;
5use crate::sources::RustdocVersion;
6use crate::sources::Source;
7use anyhow::{Result, anyhow};
8use cargo_metadata::MetadataCommand;
9use fieldwork::Fieldwork;
10use rustc_hash::FxHashMap;
11use rustc_hash::FxHashSet;
12use rustdoc_types::{Crate, FORMAT_VERSION};
13use semver::Version;
14use semver::VersionReq;
15use std::borrow::Cow;
16use std::path::Path;
17use std::path::PathBuf;
18use std::process::Command;
19use std::time::SystemTime;
20use walkdir::WalkDir;
21
22#[derive(Debug, Fieldwork)]
23#[field(get)]
24pub struct LocalSource {
25 manifest_path: PathBuf,
26 target_dir: PathBuf,
27 #[field = false]
28 crates: FxHashMap<CrateName<'static>, CrateInfo>,
29 root_crate: Option<CrateName<'static>>,
30 can_rebuild: bool,
31}
32
33impl LocalSource {
34 pub fn load(path: &Path) -> Result<Self> {
35 let metadata = if path.is_dir() {
36 MetadataCommand::new().current_dir(path).exec()?
37 } else if path.file_name().and_then(|n| n.to_str()) == Some("Cargo.toml") {
38 if !path.exists() {
39 return Err(anyhow!("Cargo.toml not found at {}", path.display()));
40 }
41 MetadataCommand::new().manifest_path(path).exec()?
42 } else {
43 return Err(anyhow!(
44 "Path must be a directory or Cargo.toml file, got: {}",
45 path.display()
46 ));
47 };
48
49 let manifest_path: PathBuf = metadata.workspace_root.join("Cargo.toml").into();
50 let mut reverse_deps: FxHashMap<&str, FxHashSet<&str>> = FxHashMap::default();
51
52 let mut workspace_packages: FxHashSet<&str> = FxHashSet::default();
53
54 for package in metadata.workspace_packages() {
55 workspace_packages.insert(&package.name);
56 for dep in &package.dependencies {
57 reverse_deps
58 .entry(&dep.name)
59 .or_default()
60 .insert(&package.name);
61 }
62 }
63
64 let target_dir = metadata.target_directory.clone().into_std_path_buf();
65 let root_crate = metadata
66 .root_package()
67 .map(|p| CrateName::from(p.name.to_string()));
68
69 let mut crates = FxHashMap::default();
70 for package in &metadata.packages {
71 let provenance = if workspace_packages.contains(&**package.name) {
78 CrateProvenance::Workspace
79 } else {
80 CrateProvenance::LocalDependency
81 };
82
83 let used_by = reverse_deps
84 .get(&**package.name)
85 .into_iter()
86 .flatten()
87 .map(|name| name.to_string())
88 .collect();
89
90 let doc_dir = target_dir.join("doc");
91 let underscored = package.name.replace('-', "_");
92 let json_path = doc_dir.join(format!("{underscored}.json"));
93
94 crates.insert(
95 package.name.to_string().into(),
96 CrateInfo {
97 provenance,
98 version: Some(package.version.clone()),
99 description: package.description.clone(),
100 name: package.name.to_string(),
101 default_crate: root_crate
102 .as_ref()
103 .is_some_and(|dc| &CrateName::from(&**package.name) == dc),
104 used_by,
105 json_path: Some(json_path),
106 },
107 );
108 }
109
110 Ok(Self {
111 manifest_path,
112 target_dir,
113 can_rebuild: true,
114 crates,
115 root_crate,
116 })
117 }
118
119 pub fn is_workspace_package(&self, crate_name: &str) -> bool {
121 let crate_name = CrateName::from(crate_name);
122 self.crates
123 .get(&crate_name)
124 .is_some_and(|crate_info| crate_info.provenance.is_workspace())
125 }
126
127 pub fn get_dependency_version<'a, 'b: 'a>(
130 &'a self,
131 crate_name: &'b str,
132 ) -> Option<&'a Version> {
133 let crate_name = CrateName::from(crate_name);
134 self.crates
135 .get(&crate_name)
136 .and_then(|lsm| lsm.version.as_ref())
137 }
138
139 pub fn project_root(&self) -> &Path {
141 self.manifest_path.parent().unwrap_or(&self.manifest_path)
142 }
143
144 pub fn can_load(&self, crate_name: &str) -> bool {
146 self.crates.contains_key(crate_name)
147 }
148
149 fn json_path(&self, crate_name: &str) -> PathBuf {
151 let doc_dir = self.target_dir.join("doc");
152 let underscored = crate_name.replace('-', "_");
153 doc_dir.join(format!("{underscored}.json"))
154 }
155
156 pub fn load_workspace_crate(&self, crate_name: CrateName<'_>) -> Option<RustdocData> {
158 let json_path = self.json_path(crate_name.as_ref());
159 let mut tried_rebuilding = false;
160
161 loop {
162 let needs_rebuild = json_path
163 .metadata()
164 .ok()
165 .and_then(|m| m.modified().ok())
166 .is_none_or(|docs_updated| {
167 WalkDir::new(self.project_root().join("src"))
168 .into_iter()
169 .filter_map(|entry| -> Option<SystemTime> {
170 entry.ok()?.metadata().ok()?.modified().ok()
171 })
172 .any(|file_updated| file_updated > docs_updated)
173 });
174
175 if !needs_rebuild
176 && let Ok(content) = std::fs::read(&json_path)
177 && let Ok(format_version) = sonic_rs::get_from_slice(&content, &["format_version"])
178 && let Ok(FORMAT_VERSION) = format_version.as_raw_str().parse()
179 {
180 let crate_data: Crate = sonic_rs::serde::from_slice(&content).ok()?;
181 let version = crate_data
182 .crate_version
183 .as_ref()
184 .and_then(|v| Version::parse(v).ok());
185
186 break Some(RustdocData {
187 crate_data,
188 name: crate_name.to_string(),
189 provenance: CrateProvenance::Workspace,
190 fs_path: json_path,
191 version,
192 path_to_id: Default::default(),
193 });
194 } else if !tried_rebuilding && self.can_rebuild {
195 tried_rebuilding = true;
196 if self.rebuild_docs(&crate_name, None).is_ok() {
197 continue;
198 }
199 }
200 break None;
201 }
202 }
203
204 pub fn load_dep(
206 &self,
207 crate_name: CrateName<'_>,
208 version: Option<&Version>,
209 ) -> Option<RustdocData> {
210 let info = self.lookup(&crate_name, &VersionReq::STAR)?;
211 let json_path = info.json_path.as_deref()?;
212 let info_version = info.version.as_ref();
213
214 if let Some(version) = version
215 && let Some(info_version) = info_version
216 && version != info_version
217 {
218 return None;
219 }
220
221 let mut tried_rebuilding = false;
222
223 loop {
224 if let Ok(content) = std::fs::read(json_path)
225 && let Ok(RustdocVersion {
226 format_version,
227 crate_version,
228 }) = sonic_rs::serde::from_slice(&content)
229 && format_version == FORMAT_VERSION
230 && crate_version.as_ref() == version
231 {
232 let crate_data: Crate = sonic_rs::serde::from_slice(&content).ok()?;
233 let version = crate_data
234 .crate_version
235 .as_ref()
236 .and_then(|v| Version::parse(v).ok());
237
238 break Some(RustdocData {
239 crate_data,
240 name: crate_name.to_string(),
241 provenance: CrateProvenance::LocalDependency,
242 fs_path: json_path.to_owned(),
243 version,
244 path_to_id: Default::default(),
245 });
246 } else if !tried_rebuilding && self.can_rebuild {
247 tried_rebuilding = true;
248 if self.rebuild_docs(&crate_name, version).is_ok() {
249 continue;
250 }
251 }
252 break None;
253 }
254 }
255
256 fn rebuild_docs(&self, crate_name: &CrateName<'_>, version: Option<&Version>) -> Result<()> {
258 let package_spec = match version {
259 Some(v) => format!("{}@{}", crate_name, v),
260 None => crate_name.to_string(),
261 };
262
263 let output = Command::new("rustup")
264 .arg("run")
265 .args([
266 "nightly",
267 "cargo",
268 "doc",
269 "--no-deps",
270 "--package",
271 &package_spec,
272 ])
273 .env("RUSTDOCFLAGS", "-Z unstable-options --output-format=json")
274 .current_dir(self.project_root())
275 .output()?;
276
277 if !output.status.success() {
278 let stderr = String::from_utf8_lossy(&output.stderr);
279 return Err(anyhow!("cargo doc failed: {}", stderr));
280 }
281 Ok(())
282 }
283}
284
285impl Source for LocalSource {
286 fn lookup<'a>(&'a self, name: &str, _version: &VersionReq) -> Option<Cow<'a, CrateInfo>> {
287 let search_name = if name == "crate" {
289 self.root_crate()?
290 } else {
291 &CrateName::from(name.to_owned())
292 };
293
294 self.crates.get(search_name).map(Cow::Borrowed)
295 }
296
297 fn load(&self, crate_name: &str, version: Option<&Version>) -> Option<RustdocData> {
298 let crate_name = CrateName::from(crate_name);
299
300 if self.is_workspace_package(&crate_name) {
301 self.load_workspace_crate(crate_name)
302 } else {
303 self.load_dep(crate_name, version)
304 }
305 }
306
307 fn list_available<'a>(&'a self) -> Box<dyn Iterator<Item = &'a CrateInfo> + '_> {
308 Box::new(self.crates.values().filter(|crate_info| {
309 crate_info.provenance.is_workspace()
310 || match self.root_crate.as_ref() {
311 Some(rc) => crate_info
312 .used_by()
313 .iter()
314 .any(|u| &CrateName::from(&**u) == rc),
315 None => !crate_info.used_by().is_empty(),
316 }
317 }))
318 }
319
320 fn canonicalize(&self, input_name: &str) -> Option<CrateName<'static>> {
321 self.crates
322 .get_key_value(input_name)
323 .map(|(k, _)| k.clone())
324 }
325}
326
327