1use camino::{Utf8Path, Utf8PathBuf};
2use itertools::Itertools;
3use log::debug;
4use scarb_metadata::{Metadata, MetadataCommand, PackageMetadata};
5use std::{collections::HashMap, ffi::OsStr, path::PathBuf};
6use thiserror::Error;
7use url::Url;
8use walkdir::WalkDir;
9
10#[derive(Debug, Error)]
11pub enum Error {
12 #[error("[E012] Invalid dependency path for '{name}': {path}\n\nSuggestions:\n • Check that the path exists and is accessible\n • Use relative paths from the current directory\n • Verify the path format is correct\n • Example: path:../my-dependency")]
13 DependencyPath { name: String, path: String },
14
15 #[error("[E013] Failed to read metadata for '{name}' at path: {path}\n\nSuggestions:\n • Check that Scarb.toml exists at the specified path\n • Verify the Scarb.toml file is valid\n • Run 'scarb metadata' in the target directory to test\n • Ensure scarb is installed and accessible")]
16 MetadataError { name: String, path: PathBuf },
17
18 #[error("[E014] Path contains invalid UTF-8 characters\n\nSuggestions:\n • Use only ASCII characters in file paths\n • Avoid special characters in directory names\n • Check for hidden or control characters in the path")]
19 Utf8(#[from] camino::FromPathBufError),
20}
21
22impl Error {
23 pub const fn error_code(&self) -> &'static str {
24 match self {
25 Self::DependencyPath { .. } => "E012",
26 Self::MetadataError { .. } => "E013",
27 Self::Utf8(_) => "E014",
28 }
29 }
30}
31
32pub fn gather_packages(
37 metadata: &Metadata,
38 packages: &mut Vec<PackageMetadata>,
39) -> Result<(), Error> {
40 let mut workspace_packages: Vec<PackageMetadata> = metadata
41 .packages
42 .clone()
43 .into_iter()
44 .filter(|package_meta| metadata.workspace.members.contains(&package_meta.id))
45 .filter(|package_meta| !packages.contains(package_meta))
46 .collect();
47
48 let workspace_packages_names = workspace_packages
49 .iter()
50 .map(|package| package.name.clone())
51 .collect_vec();
52
53 let mut dependencies: HashMap<String, PathBuf> = HashMap::new();
55 for package in &workspace_packages {
56 for dependency in &package.dependencies {
57 let name = &dependency.name;
58 let url = Url::parse(&dependency.source.repr).map_err(|_| Error::DependencyPath {
59 name: name.clone(),
60 path: dependency.source.repr.clone(),
61 })?;
62
63 if url.scheme().starts_with("path") {
64 let path = url.to_file_path().map_err(|()| Error::DependencyPath {
65 name: name.clone(),
66 path: dependency.source.repr.clone(),
67 })?;
68 dependencies.insert(name.clone(), path);
69 }
70 }
71 }
72
73 packages.append(&mut workspace_packages);
74
75 let out_of_workspace_dependencies: HashMap<&String, &PathBuf> = dependencies
77 .iter()
78 .filter(|&(k, _)| !workspace_packages_names.contains(k))
79 .collect();
80
81 for (name, manifest) in out_of_workspace_dependencies {
82 let new_meta = MetadataCommand::new()
83 .json()
84 .manifest_path(manifest)
85 .exec()
86 .map_err(|_| Error::MetadataError {
87 name: name.clone(),
88 path: manifest.clone(),
89 })?;
90 gather_packages(&new_meta, packages)?;
91 }
92
93 Ok(())
94}
95
96pub fn package_sources(package_metadata: &PackageMetadata) -> Result<Vec<Utf8PathBuf>, Error> {
101 package_sources_with_test_files(package_metadata, false)
102}
103
104pub fn package_sources_with_test_files(
109 package_metadata: &PackageMetadata,
110 include_test_files: bool,
111) -> Result<Vec<Utf8PathBuf>, Error> {
112 debug!("Collecting sources for package: {}", package_metadata.name);
113 debug!("Package root: {}", package_metadata.root);
114 debug!("Package manifest: {}", package_metadata.manifest_path);
115
116 let mut sources: Vec<Utf8PathBuf> = WalkDir::new(package_metadata.root.clone())
117 .into_iter()
118 .filter_map(std::result::Result::ok)
119 .filter(|f| f.file_type().is_file())
120 .filter(|f| {
121 if let Some(path_str) = f.path().to_str() {
123 let is_in_src = path_str.contains("/src/");
125 let has_test_in_path = path_str.contains("/test") || path_str.contains("/tests/");
126
127 if is_in_src && has_test_in_path {
128 return include_test_files;
130 }
131
132 if path_str.contains("/tests/")
134 || path_str.contains("/test/")
135 || path_str.contains("/examples/")
136 || path_str.contains("/benchmarks/")
137 {
138 return false;
139 }
140 }
141
142 if let Some(ext) = f.path().extension() {
144 if ext == OsStr::new(CAIRO_EXT) || ext == OsStr::new("rs") {
145 return true;
146 }
147 }
148
149 if f.file_name() == OsStr::new("Scarb.toml")
151 || f.file_name() == OsStr::new("Cargo.toml")
152 {
153 return true;
154 }
155
156 false
157 })
158 .map(walkdir::DirEntry::into_path)
159 .map(Utf8PathBuf::try_from)
160 .try_collect()?;
161
162 if !sources.contains(&package_metadata.manifest_path) {
164 sources.push(package_metadata.manifest_path.clone());
165 }
166
167 let package_root = &package_metadata.root;
168
169 if let Some(lic) = package_metadata
170 .manifest_metadata
171 .license_file
172 .as_ref()
173 .map(Utf8Path::new)
174 .map(Utf8Path::to_path_buf)
175 {
176 sources.push(package_root.join(lic));
177 }
178
179 if let Some(readme) = package_metadata
180 .manifest_metadata
181 .readme
182 .as_deref()
183 .map(Utf8Path::new)
184 .map(Utf8Path::to_path_buf)
185 {
186 sources.push(package_root.join(readme));
187 }
188
189 Ok(sources)
190}
191
192pub fn biggest_common_prefix<P: AsRef<Utf8Path> + Clone>(
193 paths: &[Utf8PathBuf],
194 first_guess: P,
195) -> Utf8PathBuf {
196 let ancestors = Utf8Path::ancestors(first_guess.as_ref());
197 let mut biggest_prefix: &Utf8Path = first_guess.as_ref();
198 for prefix in ancestors {
199 if paths.iter().all(|src| src.starts_with(prefix)) {
200 biggest_prefix = prefix;
201 break;
202 }
203 }
204 biggest_prefix.to_path_buf()
205}
206
207const CAIRO_EXT: &str = "cairo";
208
209#[cfg(test)]
210#[allow(clippy::unwrap_used)]
211mod tests {
212 use super::*;
213 use camino::Utf8PathBuf;
214 use std::path::PathBuf;
215 use tempfile::TempDir;
216
217 #[test]
218 fn test_biggest_common_prefix_simple() {
219 let paths = vec![
220 Utf8PathBuf::from("/root/project/src/lib.cairo"),
221 Utf8PathBuf::from("/root/project/src/main.cairo"),
222 Utf8PathBuf::from("/root/project/tests/test.cairo"),
223 ];
224 let first_guess = Utf8PathBuf::from("/root/project/src/lib.cairo");
225 let result = biggest_common_prefix(&paths, first_guess);
226 assert_eq!(result, Utf8PathBuf::from("/root/project"));
227 }
228
229 #[test]
230 fn test_biggest_common_prefix_no_common() {
231 let paths = vec![
232 Utf8PathBuf::from("/root/project1/src/lib.cairo"),
233 Utf8PathBuf::from("/root/project2/src/main.cairo"),
234 ];
235 let first_guess = Utf8PathBuf::from("/root/project1/src/lib.cairo");
236 let result = biggest_common_prefix(&paths, first_guess);
237 assert_eq!(result, Utf8PathBuf::from("/root"));
238 }
239
240 #[test]
241 fn test_biggest_common_prefix_exact_match() {
242 let paths = vec![Utf8PathBuf::from("/root/project/src/lib.cairo")];
243 let first_guess = Utf8PathBuf::from("/root/project/src/lib.cairo");
244 let result = biggest_common_prefix(&paths, first_guess);
245 assert_eq!(result, Utf8PathBuf::from("/root/project/src/lib.cairo"));
246 }
247
248 #[test]
249 fn test_error_display() {
250 let error = Error::DependencyPath {
251 name: "test_package".to_string(),
252 path: "/invalid/path".to_string(),
253 };
254 let error_message = format!("{error}");
255 assert!(error_message.contains("[E012]"));
256 assert!(error_message.contains("Invalid dependency path"));
257 assert!(error_message.contains("test_package"));
258 assert!(error_message.contains("/invalid/path"));
259 assert!(error_message.contains("Check that the path exists"));
260 }
261
262 #[test]
263 fn test_cairo_extension_constant() {
264 assert_eq!(CAIRO_EXT, "cairo");
265 }
266
267 #[test]
268 fn test_file_filtering_logic() {
269 let temp_dir = TempDir::new().unwrap();
270 let temp_path = PathBuf::from(temp_dir.path());
271
272 std::fs::create_dir_all(temp_path.join("src")).unwrap();
274 std::fs::create_dir_all(temp_path.join("tests")).unwrap();
275 std::fs::create_dir_all(temp_path.join("examples")).unwrap();
276
277 std::fs::write(temp_path.join("src").join("lib.cairo"), "").unwrap();
279 std::fs::write(temp_path.join("src").join("main.cairo"), "").unwrap();
280 std::fs::write(temp_path.join("tests").join("test.cairo"), "").unwrap();
281 std::fs::write(temp_path.join("examples").join("example.cairo"), "").unwrap();
282 std::fs::write(temp_path.join("Scarb.toml"), "").unwrap();
283 std::fs::write(temp_path.join("other.txt"), "").unwrap();
284
285 let cairo_files: Vec<_> = walkdir::WalkDir::new(&temp_path)
287 .into_iter()
288 .filter_map(std::result::Result::ok)
289 .filter(|f| f.file_type().is_file())
290 .filter(|f| {
291 if let Some(path_str) = f.path().to_str() {
293 if path_str.contains("/tests/")
294 || path_str.contains("/test/")
295 || path_str.contains("/examples/")
296 || path_str.contains("/benchmarks/")
297 {
298 return false;
299 }
300 }
301
302 if let Some(ext) = f.path().extension() {
304 if ext == std::ffi::OsStr::new(CAIRO_EXT) {
305 return true;
306 }
307 }
308
309 if f.file_name() == std::ffi::OsStr::new("Scarb.toml") {
311 return true;
312 }
313
314 false
315 })
316 .collect();
317
318 assert!(cairo_files
320 .iter()
321 .any(|f| f.file_name() == std::ffi::OsStr::new("lib.cairo")));
322 assert!(cairo_files
323 .iter()
324 .any(|f| f.file_name() == std::ffi::OsStr::new("main.cairo")));
325 assert!(cairo_files
326 .iter()
327 .any(|f| f.file_name() == std::ffi::OsStr::new("Scarb.toml")));
328 assert!(!cairo_files
329 .iter()
330 .any(|f| f.path().to_str().unwrap().contains("/tests/")));
331 assert!(!cairo_files
332 .iter()
333 .any(|f| f.path().to_str().unwrap().contains("/examples/")));
334 assert!(!cairo_files
335 .iter()
336 .any(|f| f.file_name() == std::ffi::OsStr::new("other.txt")));
337 }
338}