Skip to main content

forge_doc/
utils.rs

1//! `GitSource` + `Deployments` helpers.
2//!
3//! Pure functions ported from the legacy `forge-doc` preprocessors:
4//! * `git_source_url`: `<repo>/blob/<commit>/<rel>` for a source file.
5//! * `read_deployments`: load `<dir>/<network>/<contract>.json` artifacts.
6
7use alloy_primitives::Address;
8use path_slash::PathExt;
9use serde::Deserialize;
10use solar::ast::ContractKind;
11use std::{
12    fs,
13    path::{Component, Path, PathBuf},
14};
15
16// ── git source ────────────────────────────────────────────────────────────────
17
18/// Build the `{repo}/blob/{commit}/{rel}` URL for a source file.
19///
20/// Returns `None` if `item_path` is not under `root` (i.e. for absolute external paths).
21/// `commit` falls back to `"HEAD"` when empty (GitHub's `blob/HEAD/...` resolves
22/// to the repository's default branch regardless of whether it is `main`,
23/// `master`, or anything else).
24///
25/// Path components are joined with `/` so the URL is well-formed on Windows.
26pub fn git_source_url(repo: &str, commit: &str, root: &Path, item_path: &Path) -> Option<String> {
27    let repo = repo.trim_end_matches('/');
28    let commit = if commit.is_empty() { "HEAD" } else { commit };
29    let rel = item_path.strip_prefix(root).ok()?;
30    Some(format!("{repo}/blob/{commit}/{}", rel.to_slash_lossy()))
31}
32
33/// Return a `{repo}/raw/{commit}/...` URL suitable for embedding binary assets
34/// (images, fonts, etc.) directly rather than the GitHub blob viewer page.
35pub fn git_raw_url(repo: &str, commit: &str, root: &Path, item_path: &Path) -> Option<String> {
36    let repo = repo.trim_end_matches('/');
37    let commit = if commit.is_empty() { "HEAD" } else { commit };
38    let rel = item_path.strip_prefix(root).ok()?;
39    Some(format!("{repo}/raw/{commit}/{}", rel.to_slash_lossy()))
40}
41
42// ── deployments ──────────────────────────────────────────────────────────────
43
44/// A contract deployment entry, deserialised from `<dir>/<network>/<contract>.json`.
45#[derive(Clone, Debug, Deserialize)]
46pub struct Deployment {
47    /// The contract address.
48    pub address: Address,
49    /// The network name (filled in from the parent directory name).
50    pub network: Option<String>,
51}
52
53/// Read all deployments for a Solidity contract file.
54///
55/// Walks `deployments_dir` (defaulting to `<root>/deployments`), looks for a
56/// `<network>/<contract-stem>.json` file in each top-level subdirectory, and
57/// returns the parsed [`Deployment`]s tagged with their network name.
58///
59/// Errors when reading individual entries are silently skipped to mirror the
60/// legacy preprocessor's lenient behaviour.
61pub fn read_deployments(
62    root: &Path,
63    deployments_dir: Option<&Path>,
64    contract_file: &Path,
65) -> Vec<Deployment> {
66    let dir = root.join(deployments_dir.unwrap_or_else(|| Path::new("deployments")));
67    let Ok(entries) = fs::read_dir(&dir) else {
68        return Vec::new();
69    };
70
71    // Switch ".sol" -> ".json" and keep just the file name.
72    let mut filename: PathBuf = contract_file.to_path_buf();
73    filename.set_extension("json");
74    let Some(filename) = filename.file_name().map(PathBuf::from) else { return Vec::new() };
75
76    let mut out = Vec::new();
77    for entry in entries.flatten() {
78        let Ok(file_type) = entry.file_type() else { continue };
79        if !file_type.is_dir() {
80            continue;
81        }
82        let Ok(network) = entry.file_name().into_string() else { continue };
83        let path = entry.path().join(&filename);
84        let Ok(content) = fs::read_to_string(&path) else { continue };
85        let Ok(mut deployment) = serde_json::from_str::<Deployment>(&content) else { continue };
86        deployment.network = Some(network);
87        out.push(deployment);
88    }
89    // Sort for deterministic output across platforms (fs::read_dir order is unspecified).
90    out.sort_by(|a, b| {
91        a.network.as_deref().unwrap_or("").cmp(b.network.as_deref().unwrap_or("")).then_with(|| {
92            let af = format!("{:#x}", a.address);
93            let bf = format!("{:#x}", b.address);
94            af.cmp(&bf)
95        })
96    });
97    out
98}
99
100/// Map a source to its documentation location, retaining the legacy external-library layout.
101pub(crate) fn relative_source_path(root: &Path, source: &Path) -> PathBuf {
102    source.strip_prefix(root).map(Path::to_path_buf).unwrap_or_else(|_| {
103        let components = source.components().collect::<Vec<_>>();
104        PathBuf::from("lib")
105            .join(components[components.len().saturating_sub(3)..].iter().collect::<PathBuf>())
106    })
107}
108
109/// Whether joining a generated path can stay within the documentation directory.
110pub(crate) fn is_safe_output_path(path: &Path) -> bool {
111    !path.is_absolute()
112        && !path.components().any(|component| {
113            matches!(component, Component::ParentDir | Component::RootDir | Component::Prefix(_))
114        })
115}
116
117/// The filename prefix used for a contract page.
118pub(crate) const fn contract_kind_str(kind: ContractKind) -> &'static str {
119    match kind {
120        ContractKind::Contract => "contract",
121        ContractKind::AbstractContract => "abstract",
122        ContractKind::Interface => "interface",
123        ContractKind::Library => "library",
124    }
125}
126
127/// Construct an item page beside its source in the documentation tree.
128pub(crate) fn page_path(source: &Path, prefix: &str, name: &str) -> PathBuf {
129    source.parent().unwrap_or(Path::new("")).join(format!("{prefix}.{name}.mdx"))
130}