1pub 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
10pub 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 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 pub async fn scan_project(
41 &self,
42 project_dir: &Path,
43 ) -> Result<(Vec<ScannedDependency>, Vec<SkippedPackage>, String)> {
44 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 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 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 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 pub fn get_stats(&self, dependencies: &[ScannedDependency]) -> DependencyStats {
119 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 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#[derive(Debug, Clone)]
162pub struct ScannedDependency {
163 pub name: crate::types::PackageName,
165 pub version: crate::types::Version,
167 pub is_direct: bool,
169 pub source: DependencySource,
171 pub path: Option<std::path::PathBuf>,
173 pub source_file: Option<String>,
175}
176
177#[derive(Debug, Clone)]
179pub enum DependencySource {
180 Registry,
182 Git { url: String, rev: Option<String> },
184 Path,
186 Url(String),
188}
189
190impl ScannedDependency {
191 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
202impl 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 #![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 #[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 #[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}