verifier/
resolver.rs

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
32/// # Errors
33///
34/// Will return `Err` if it can't read files from the directory that
35/// metadata points to.
36pub 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    // find all dependencies listed by path
54    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    // filter out dependencies already covered by workspace
76    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
96/// # Errors
97///
98/// Will return `Err` if it can't read files from the directory that
99/// metadata points to.
100pub fn package_sources(package_metadata: &PackageMetadata) -> Result<Vec<Utf8PathBuf>, Error> {
101    package_sources_with_test_files(package_metadata, false)
102}
103
104/// # Errors
105///
106/// Will return `Err` if it can't read files from the directory that
107/// metadata points to.
108pub 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            // Check if this is a test file
122            if let Some(path_str) = f.path().to_str() {
123                // Check if the path contains test directories but only if it's in src/
124                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                    // This is a test file in src/
129                    return include_test_files;
130                }
131
132                // Exclude test directories outside src/
133                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            // Include Cairo files and Rust files
143            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            // Include Scarb.toml and Cargo.toml files (being more explicit)
150            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    // Ensure the package's own manifest is included
163    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        // Create test directory structure
273        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        // Create test files
278        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        // Test the filtering logic used in package_sources
286        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                // Test the exclusion logic
292                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                // Test the inclusion logic
303                if let Some(ext) = f.path().extension() {
304                    if ext == std::ffi::OsStr::new(CAIRO_EXT) {
305                        return true;
306                    }
307                }
308
309                // Test Scarb.toml inclusion
310                if f.file_name() == std::ffi::OsStr::new("Scarb.toml") {
311                    return true;
312                }
313
314                false
315            })
316            .collect();
317
318        // Should include cairo files from src, Scarb.toml, but not from tests or examples
319        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}