Skip to main content

pysentry/dependency/
scanner.rs

1// SPDX-License-Identifier: MIT
2
3pub use crate::parsers::DependencyStats;
4use crate::parsers::{ParsedDependency, ParserRegistry, SkippedPackage};
5use crate::Result;
6use std::collections::HashSet;
7use std::path::Path;
8use tracing::{debug, info};
9
10/// Main dependency scanner that orchestrates parsing using the parser registry
11pub struct DependencyScanner {
12    parser_registry: ParserRegistry,
13    include_dev: bool,
14    include_optional: bool,
15    direct_only: bool,
16    has_groups: bool,
17}
18
19impl DependencyScanner {
20    /// Create a new dependency scanner with specified options
21    pub fn new(
22        include_dev: bool,
23        include_optional: bool,
24        direct_only: bool,
25        resolver: Option<crate::types::ResolverType>,
26        groups: Option<HashSet<String>>,
27    ) -> Self {
28        let has_groups = groups.is_some();
29        Self {
30            parser_registry: ParserRegistry::new(resolver, groups),
31            include_dev,
32            include_optional,
33            direct_only,
34            has_groups,
35        }
36    }
37
38    /// Scan dependencies from a project directory using the best available parser
39    /// Returns (dependencies, skipped_packages, parser_name)
40    pub async fn scan_project(
41        &self,
42        project_dir: &Path,
43    ) -> Result<(Vec<ScannedDependency>, Vec<SkippedPackage>, String)> {
44        // The CLI rejects `--group` + `--exclude-extra` in perform_audit, but library callers
45        // reach this constructor directly. Enforce the same invariant here: group filtering
46        // already narrows scope via the reachability closure, and dropping optional
47        // dependencies on top would strip the very groups being selected, silently producing
48        // a narrower result than intended.
49        if self.has_groups && !self.include_optional {
50            return Err(crate::AuditError::other(
51                "group filtering cannot be combined with include_optional = false: the group \
52                 reachability filter already narrows scope, and excluding optional dependencies \
53                 would strip the selected groups. Enable include_optional or drop the groups.",
54            ));
55        }
56
57        // Only the group-aware lock parsers (uv.lock, poetry.lock, pylock.toml) apply the group
58        // reachability filter. Without one of them the registry falls through to a non-group-aware
59        // parser (pyproject.toml, Pipfile, requirements.txt) that would silently scan the full
60        // dependency set, ignoring `groups` entirely. The CLI rejects this in perform_audit; mirror
61        // it here so library callers get the same fail-fast behavior instead of a wrong result.
62        // Only the lock-file presence check is mirrored — that is what prevents the unfiltered scan.
63        // Group-name existence validation (perform_audit's list_group_names check) stays CLI-only by
64        // design: an unknown group yields a narrow/empty result, not a silently-broadened one.
65        if self.has_groups && !crate::parsers::has_group_aware_lock(project_dir) {
66            return Err(crate::AuditError::other(
67                "group filtering requires a lock file (uv.lock, poetry.lock, or pylock.toml) in the \
68                 project directory; without one, the selected parser cannot apply the group filter \
69                 and would scan the full dependency set. Generate a lock file or drop the groups.",
70            ));
71        }
72
73        debug!("Scanning dependencies in: {}", project_dir.display());
74
75        // Use parser registry to automatically select and parse with the best parser
76        let (parsed_dependencies, skipped_packages, parser_name) = self
77            .parser_registry
78            .parse_project(
79                project_dir,
80                self.include_dev,
81                self.include_optional,
82                self.direct_only,
83            )
84            .await?;
85
86        info!("Used parser: {} for project scanning", parser_name);
87        debug!("Parsed {} dependencies", parsed_dependencies.len());
88        if !skipped_packages.is_empty() {
89            debug!("Skipped {} packages", skipped_packages.len());
90        }
91
92        // Convert ParsedDependency to ScannedDependency (keeping backward compatibility)
93        let scanned_dependencies: Vec<ScannedDependency> = parsed_dependencies
94            .into_iter()
95            .map(|dep| ScannedDependency {
96                name: dep.name,
97                version: dep.version,
98                is_direct: dep.is_direct,
99                source: dep.source.into(),
100                path: dep.path,
101                source_file: dep.source_file,
102            })
103            .collect();
104
105        debug!(
106            "Successfully scanned {} dependencies using parser: {}",
107            scanned_dependencies.len(),
108            parser_name
109        );
110        Ok((
111            scanned_dependencies,
112            skipped_packages,
113            parser_name.to_string(),
114        ))
115    }
116
117    /// Get dependency statistics
118    pub fn get_stats(&self, dependencies: &[ScannedDependency]) -> DependencyStats {
119        // Convert ScannedDependency back to ParsedDependency for stats calculation
120        let parsed_deps: Vec<ParsedDependency> = dependencies
121            .iter()
122            .map(|dep| ParsedDependency {
123                name: dep.name.clone(),
124                version: dep.version.clone(),
125                is_direct: dep.is_direct,
126                source: dep.source.clone().into(),
127                path: dep.path.clone(),
128                source_file: dep.source_file.clone(),
129            })
130            .collect();
131
132        DependencyStats::from_dependencies(&parsed_deps)
133    }
134
135    /// Validate dependencies and return warnings
136    pub fn validate_dependencies(
137        &self,
138        dependencies: &[ScannedDependency],
139        skipped_packages: &[SkippedPackage],
140        parser_name: &str,
141    ) -> Vec<String> {
142        let parsed_deps: Vec<ParsedDependency> = dependencies
143            .iter()
144            .map(|dep| ParsedDependency {
145                name: dep.name.clone(),
146                version: dep.version.clone(),
147                is_direct: dep.is_direct,
148                source: dep.source.clone().into(),
149                path: dep.path.clone(),
150                source_file: dep.source_file.clone(),
151            })
152            .collect();
153
154        self.parser_registry
155            .validate_dependencies(&parsed_deps, skipped_packages, parser_name)
156    }
157}
158
159/// Backward compatibility: ScannedDependency structure
160/// This maintains compatibility with the existing codebase while we transition
161#[derive(Debug, Clone)]
162pub struct ScannedDependency {
163    /// Package name
164    pub name: crate::types::PackageName,
165    /// Installed version
166    pub version: crate::types::Version,
167    /// Whether this is a direct dependency (listed in pyproject.toml)
168    pub is_direct: bool,
169    /// Source of the dependency (PyPI, git, path, etc.)
170    pub source: DependencySource,
171    /// Optional path for path dependencies
172    pub path: Option<std::path::PathBuf>,
173    /// Source file where this dependency was parsed from (e.g., "uv.lock", "poetry.lock")
174    pub source_file: Option<String>,
175}
176
177/// Backward compatibility: DependencySource enum
178#[derive(Debug, Clone)]
179pub enum DependencySource {
180    /// PyPI registry
181    Registry,
182    /// Git repository
183    Git { url: String, rev: Option<String> },
184    /// Local path
185    Path,
186    /// Direct URL
187    Url(String),
188}
189
190impl ScannedDependency {
191    /// Names of the direct (top-level) dependencies in a scan, used to seed
192    /// transitive-root attribution. See `crate::parsers::graph`.
193    pub fn direct_names(dependencies: &[ScannedDependency]) -> HashSet<crate::types::PackageName> {
194        dependencies
195            .iter()
196            .filter(|dep| dep.is_direct)
197            .map(|dep| dep.name.clone())
198            .collect()
199    }
200}
201
202// Conversion implementations for backward compatibility
203impl From<crate::parsers::DependencySource> for DependencySource {
204    fn from(source: crate::parsers::DependencySource) -> Self {
205        match source {
206            crate::parsers::DependencySource::Registry => DependencySource::Registry,
207            crate::parsers::DependencySource::Git { url, rev } => {
208                DependencySource::Git { url, rev }
209            }
210            crate::parsers::DependencySource::Path => DependencySource::Path,
211            crate::parsers::DependencySource::Url(url) => DependencySource::Url(url),
212        }
213    }
214}
215
216impl From<DependencySource> for crate::parsers::DependencySource {
217    fn from(source: DependencySource) -> Self {
218        match source {
219            DependencySource::Registry => crate::parsers::DependencySource::Registry,
220            DependencySource::Git { url, rev } => {
221                crate::parsers::DependencySource::Git { url, rev }
222            }
223            DependencySource::Path => crate::parsers::DependencySource::Path,
224            DependencySource::Url(url) => crate::parsers::DependencySource::Url(url),
225        }
226    }
227}
228
229impl Default for DependencyScanner {
230    fn default() -> Self {
231        Self::new(false, false, false, None, None)
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    // Indexing into fixtures/parsed results is the norm in tests; a panic on a
238    // bad index is an acceptable test failure.
239    #![allow(clippy::indexing_slicing)]
240    use super::*;
241    use std::str::FromStr;
242
243    #[test]
244    fn test_dependency_scanner_creation() {
245        let scanner = DependencyScanner::new(true, true, false, None, None);
246        assert!(scanner.include_dev);
247        assert!(scanner.include_optional);
248        assert!(!scanner.direct_only);
249    }
250
251    #[test]
252    fn test_dependency_source_conversion() {
253        let parser_source = crate::parsers::DependencySource::Registry;
254        let scanner_source: DependencySource = parser_source.into();
255
256        match scanner_source {
257            DependencySource::Registry => (),
258            _ => panic!("Conversion failed"),
259        }
260    }
261
262    #[test]
263    fn test_dependency_stats_calculation() {
264        let dependencies = vec![
265            ScannedDependency {
266                name: crate::types::PackageName::from_str("package1").unwrap(),
267                version: crate::types::Version::from_str("1.0.0").unwrap(),
268                is_direct: true,
269                source: DependencySource::Registry,
270                path: None,
271                source_file: None,
272            },
273            ScannedDependency {
274                name: crate::types::PackageName::from_str("package2").unwrap(),
275                version: crate::types::Version::from_str("2.0.0").unwrap(),
276                is_direct: false,
277                source: DependencySource::Registry,
278                path: None,
279                source_file: None,
280            },
281        ];
282
283        let scanner = DependencyScanner::default();
284        let stats = scanner.get_stats(&dependencies);
285
286        assert_eq!(stats.total_packages, 2);
287        assert_eq!(stats.direct_packages, 1);
288        assert_eq!(stats.transitive_packages, 1);
289    }
290
291    #[test]
292    fn test_direct_names_keeps_only_direct() {
293        let dependencies = vec![
294            ScannedDependency {
295                name: crate::types::PackageName::from_str("direct-pkg").unwrap(),
296                version: crate::types::Version::from_str("1.0.0").unwrap(),
297                is_direct: true,
298                source: DependencySource::Registry,
299                path: None,
300                source_file: None,
301            },
302            ScannedDependency {
303                name: crate::types::PackageName::from_str("transitive-pkg").unwrap(),
304                version: crate::types::Version::from_str("2.0.0").unwrap(),
305                is_direct: false,
306                source: DependencySource::Registry,
307                path: None,
308                source_file: None,
309            },
310        ];
311
312        let names = ScannedDependency::direct_names(&dependencies);
313        assert_eq!(
314            names,
315            HashSet::from([crate::types::PackageName::from_str("direct-pkg").unwrap()])
316        );
317    }
318
319    #[test]
320    fn test_validation_empty_dependencies() {
321        let scanner = DependencyScanner::default();
322        let warnings = scanner.validate_dependencies(&[], &[], "test_parser");
323        assert!(!warnings.is_empty());
324        assert!(warnings[0].contains("No dependencies found"));
325    }
326
327    #[test]
328    fn test_validation_placeholder_versions() {
329        let dependencies = vec![ScannedDependency {
330            name: crate::types::PackageName::from_str("package1").unwrap(),
331            version: crate::types::Version::new([0, 0, 0]),
332            is_direct: true,
333            source: DependencySource::Registry,
334            path: None,
335            source_file: None,
336        }];
337
338        let scanner = DependencyScanner::default();
339
340        let warnings = scanner.validate_dependencies(&dependencies, &[], "requirements.txt");
341        assert!(warnings.iter().any(|w| w.contains("placeholder versions")));
342        assert!(warnings
343            .iter()
344            .any(|w| w.contains("Consider using a lock file")));
345        let warnings = scanner.validate_dependencies(&dependencies, &[], "uv.lock");
346        assert!(!warnings.iter().any(|w| w.contains("placeholder versions")));
347    }
348
349    #[test]
350    fn test_validation_with_skipped_packages() {
351        use crate::parsers::{SkipReason, SkippedPackage};
352        use crate::types::PackageName;
353        use std::str::FromStr;
354
355        let dependencies = vec![ScannedDependency {
356            name: PackageName::from_str("normal-package").unwrap(),
357            version: crate::types::Version::from_str("1.0.0").unwrap(),
358            is_direct: true,
359            source: DependencySource::Registry,
360            path: None,
361            source_file: None,
362        }];
363
364        let skipped_packages = vec![
365            SkippedPackage {
366                name: PackageName::from_str("virtual-package").unwrap(),
367                reason: SkipReason::Virtual,
368            },
369            SkippedPackage {
370                name: PackageName::from_str("editable-package").unwrap(),
371                reason: SkipReason::Editable,
372            },
373        ];
374
375        let scanner = DependencyScanner::default();
376        let warnings = scanner.validate_dependencies(&dependencies, &skipped_packages, "uv.lock");
377
378        assert!(
379            warnings.iter().any(|w| w.contains("Skipped 2 packages")),
380            "Expected warning about skipped packages, got: {warnings:?}"
381        );
382        assert!(
383            warnings.iter().any(|w| w.contains("virtual-package")),
384            "Expected warning mentioning virtual-package, got: {warnings:?}"
385        );
386        assert!(
387            warnings.iter().any(|w| w.contains("editable-package")),
388            "Expected warning mentioning editable-package, got: {warnings:?}"
389        );
390        assert!(
391            warnings
392                .iter()
393                .any(|w| w.contains("pip freeze") || w.contains("editable")),
394            "Expected guidance about editable packages, got: {warnings:?}"
395        );
396    }
397
398    // Library callers bypass the CLI's perform_audit guard, so scan_project must reject the
399    // groups + include_optional=false combo itself. The guard fires before any filesystem
400    // access, so an empty temp dir is enough to exercise it.
401    #[tokio::test]
402    async fn test_scan_project_rejects_groups_without_optional() {
403        let groups: HashSet<String> = ["dev".to_string()].into();
404        let scanner = DependencyScanner::new(false, false, false, None, Some(groups));
405        let temp_dir = tempfile::TempDir::new().unwrap();
406
407        let result = scanner.scan_project(temp_dir.path()).await;
408
409        assert!(
410            result.is_err(),
411            "groups + include_optional=false must be rejected"
412        );
413        let msg = result.unwrap_err().to_string();
414        assert!(
415            msg.contains("include_optional"),
416            "error must explain the incompatible flags, got: {msg}"
417        );
418    }
419
420    // groups + include_optional=true clears the first guard, but a project with no group-aware
421    // lock file would fall through to a non-group-aware parser that silently ignores `groups`.
422    // scan_project must reject this up-front, mirroring the CLI's perform_audit preflight, so a
423    // library caller gets a clear error instead of an unfiltered scan.
424    #[tokio::test]
425    async fn test_scan_project_rejects_groups_without_lock_file() {
426        let groups: HashSet<String> = ["dev".to_string()].into();
427        let scanner = DependencyScanner::new(false, true, false, None, Some(groups));
428        let temp_dir = tempfile::TempDir::new().unwrap();
429        std::fs::write(
430            temp_dir.path().join("pyproject.toml"),
431            b"[project]\nname = \"x\"\n\n[dependency-groups]\ndev = [\"httpx>=0.27\"]\n",
432        )
433        .unwrap();
434
435        let result = scanner.scan_project(temp_dir.path()).await;
436
437        assert!(
438            result.is_err(),
439            "groups without a group-aware lock file must be rejected"
440        );
441        let msg = result.unwrap_err().to_string();
442        assert!(
443            msg.contains("lock file"),
444            "error must point at the missing lock file, got: {msg}"
445        );
446    }
447}