Skip to main content

ferritin_common/sources/
local.rs

1use 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 is_crates_io = package
72            //     .source
73            //     .as_ref()
74            //     .map(|s| s.repr.starts_with("registry+"))
75            //     .unwrap_or(false);
76
77            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    /// Check if a crate name is a workspace package
120    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    /// Get the resolved version for a dependency
128    /// Returns None if not a dependency or if it's a path/workspace dep
129    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    /// Get the project root
140    pub fn project_root(&self) -> &Path {
141        self.manifest_path.parent().unwrap_or(&self.manifest_path)
142    }
143
144    /// Check if this source can provide a given crate
145    pub fn can_load(&self, crate_name: &str) -> bool {
146        self.crates.contains_key(crate_name)
147    }
148
149    /// Get the JSON path for a crate
150    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    /// Load a workspace crate (may rebuild if needed)
157    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    /// Load a dependency crate (may rebuild if needed)
205    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    /// Rebuild documentation for a crate
257    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        // Handle "crate" alias for single-package workspaces
288        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// .filter(|c| {
328//     root_crate.is_none_or(|rc| {
329//         !c.provenance().is_local_dependency() || c.used_by().iter().any(|u| **u == **rc)
330//     })
331// })