fob_graph/
module_id.rs

1use std::borrow::Cow;
2use std::fmt;
3use std::io;
4use std::path::{Path, PathBuf};
5
6use path_clean::PathClean;
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8use thiserror::Error;
9
10const VIRTUAL_PREFIX: &str = "virtual:";
11
12/// Canonical identifier for a module in the Joy graph.
13///
14/// The identifier prefers canonical filesystem paths so we can safely compare modules
15/// originating from different user inputs (relative vs absolute, `.` vs `..`, etc.).
16/// When Rolldown emits virtual modules (e.g. `virtual:entry`), we retain the virtual
17/// prefix and skip canonicalisation altogether.
18#[derive(Debug, Clone, PartialEq, Eq, Hash)]
19pub struct ModuleId(PathBuf);
20
21impl ModuleId {
22    /// Create a new module identifier from a filesystem path.
23    pub fn new(path: impl AsRef<Path>) -> Result<Self, ModuleIdError> {
24        let path = path.as_ref();
25
26        if path.as_os_str().is_empty() {
27            return Err(ModuleIdError::EmptyPath);
28        }
29
30        if looks_like_virtual(path) {
31            return Ok(Self(normalize_virtual(path)));
32        }
33
34        let joined = if path.is_absolute() {
35            path.to_path_buf()
36        } else {
37            std::env::current_dir()
38                .map_err(|source| ModuleIdError::CurrentDir { source })?
39                .join(path)
40        };
41
42        let cleaned = joined.clean();
43
44        // On WASM, canonicalize is not available, so we just use the cleaned path
45        #[cfg(target_family = "wasm")]
46        {
47            Ok(Self(cleaned))
48        }
49
50        // On native platforms, try to canonicalize
51        #[cfg(not(target_family = "wasm"))]
52        {
53            match std::fs::canonicalize(&cleaned) {
54                Ok(canonical) => Ok(Self(canonical)),
55                Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(Self(cleaned)),
56                Err(err) => Err(ModuleIdError::Canonicalization {
57                    path: cleaned,
58                    source: err,
59                }),
60            }
61        }
62    }
63
64    /// Create a module identifier for a Rolldown virtual module (e.g. `virtual:...`).
65    pub fn new_virtual(id: impl Into<String>) -> Self {
66        let id = id.into();
67
68        if id.is_empty() {
69            return Self(PathBuf::from(VIRTUAL_PREFIX));
70        }
71
72        let normalized = if id.starts_with(VIRTUAL_PREFIX) {
73            id
74        } else {
75            format!("{VIRTUAL_PREFIX}{id}")
76        };
77
78        Self(PathBuf::from(normalized))
79    }
80
81    /// Returns the underlying path representation.
82    pub fn as_path(&self) -> &Path {
83        &self.0
84    }
85
86    /// Consume the identifier and return the owned path.
87    pub fn into_path(self) -> PathBuf {
88        self.0
89    }
90
91    /// Returns `true` if the identifier represents a virtual module.
92    pub fn is_virtual(&self) -> bool {
93        let text = self.path_string();
94        text.starts_with(VIRTUAL_PREFIX) || text.starts_with("rolldown:") || text.starts_with('\0')
95    }
96
97    /// Borrow the identifier as a string for logging/serialization.
98    pub fn path_string(&self) -> Cow<'_, str> {
99        self.0.to_string_lossy()
100    }
101
102    #[cfg(test)]
103    pub(crate) fn from_canonical_path(path: PathBuf) -> Self {
104        Self(path)
105    }
106
107    fn from_serialized_path(path: PathBuf) -> Self {
108        Self(path)
109    }
110}
111
112impl fmt::Display for ModuleId {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        write!(f, "{}", self.path_string())
115    }
116}
117
118impl Serialize for ModuleId {
119    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
120    where
121        S: Serializer,
122    {
123        serializer.serialize_str(&self.path_string())
124    }
125}
126
127impl<'de> Deserialize<'de> for ModuleId {
128    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
129    where
130        D: Deserializer<'de>,
131    {
132        let value = String::deserialize(deserializer)?;
133
134        if value.starts_with(VIRTUAL_PREFIX) {
135            Ok(ModuleId::new_virtual(value))
136        } else {
137            Ok(ModuleId::from_serialized_path(PathBuf::from(value)))
138        }
139    }
140}
141
142/// Error type for `ModuleId` construction failures.
143#[derive(Debug, Error)]
144pub enum ModuleIdError {
145    /// The provided path was empty.
146    #[error("module id path is empty")]
147    EmptyPath,
148
149    /// Failed to resolve the current working directory for canonicalisation.
150    #[error("failed to resolve current directory: {source}")]
151    CurrentDir {
152        #[source]
153        source: io::Error,
154    },
155
156    /// Canonicalisation failed for reasons other than `NotFound`.
157    #[error("failed to canonicalize path '{path}': {source}")]
158    Canonicalization {
159        path: PathBuf,
160        #[source]
161        source: io::Error,
162    },
163}
164
165fn looks_like_virtual(path: &Path) -> bool {
166    let text = path.to_string_lossy();
167    text.starts_with(VIRTUAL_PREFIX) || text.starts_with("rolldown:") || text.starts_with('\0')
168}
169
170fn normalize_virtual(path: &Path) -> PathBuf {
171    let text = path.to_string_lossy();
172    if text.starts_with(VIRTUAL_PREFIX) || text.starts_with("rolldown:") {
173        PathBuf::from(text.into_owned())
174    } else if text.starts_with('\0') {
175        let trimmed = text.trim_start_matches('\0');
176        PathBuf::from(format!("{VIRTUAL_PREFIX}{trimmed}"))
177    } else {
178        PathBuf::from(format!("{VIRTUAL_PREFIX}{text}"))
179    }
180}