Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions apps/dustfril-cli/src/cli.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::path::PathBuf;

use clap::{Args, Parser, Subcommand};

/// DustFril CLI
Expand All @@ -12,17 +14,29 @@ pub struct Cli {
#[derive(Subcommand)]
pub enum Commands {
/// Scan Rust artifacts
Scan,
Scan(PathArgs),

/// Analyze disk usage
Analyze,
Analyze(PathArgs),

/// Clean artifacts
Clean(CleanArgs),
}

#[derive(Args)]
pub struct PathArgs {
pub path: Option<PathBuf>,

/// Scan the entire system instead of a specific workspace.
#[arg(long)]
pub global: bool,
}

#[derive(Args)]
pub struct CleanArgs {
#[command(flatten)]
pub path_args: PathArgs,

// Preview cleanup operations without deleting files.
#[arg(long)]
pub dry_run: bool,
Expand Down
20 changes: 15 additions & 5 deletions apps/dustfril-cli/src/commands/analyze.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use std::path::Path;

use dustfril_core::models::{AnalysisResult, CleanupRecommendation};
use dustfril_core::{analyzer, detector};

use dustfril_core::models::{AnalysisResult, CleanupRecommendation};
use crate::cli::PathArgs;
use crate::shared::path::{resolve_path, validate_path};

fn print_summary(analysis: &AnalysisResult) {
let mut keep = 0;
Expand Down Expand Up @@ -55,8 +55,18 @@ fn print_summary(analysis: &AnalysisResult) {
println!("\n----------------------------------------\n");
}

pub fn execute() {
let scan_result = detector::scan(Path::new("."));
pub fn execute(args: PathArgs) {
let path = resolve_path(&args.path);

if !validate_path(&path) {
return;
}

let scan_result = if args.global {
detector::scan_global()
} else {
detector::scan_workspace(&path)
};
Comment on lines +65 to +69
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect scan_global / scan_workspace / scan to confirm what each mode returns.
fd -t f 'scan.rs' --exec cat {}

Repository: FrilLab/dustfril

Length of output: 2097


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Locate PathArgs and the doc comment for `global`
fd -t f '.*\.rs$' apps/dustfril-cli/src --exec rg -n "struct PathArgs|global:|global\\b" {}

# Narrow further to the file likely containing PathArgs
fd -t f 'PathArgs' apps/dustfril-cli/src --exec sed -n '1,200p' {} 

# 2) Find all call sites of detector::scan and detector::scan_global / scan_workspace
rg -n "detector::scan\\b|detector::scan_global\\b|detector::scan_workspace\\b" -S apps/dustfril-cli/src

Repository: FrilLab/dustfril

Length of output: 346


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 3) Inspect dustfril_core detector module entry points (scan/scan_global/scan_workspace)
# Find where detector is re-exported from / defines scan()
fd -t f 'detector.*\.rs$|mod\.rs$' . --exec rg -n "pub fn scan\\b|pub fn scan_global\\b|pub fn scan_workspace\\b" {}

Repository: FrilLab/dustfril

Length of output: 42


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 4) Inspect the CLI docs/README/help text for the `--global` flag
rg -n "--global|global\\b" apps/dustfril-cli -S

Repository: FrilLab/dustfril

Length of output: 502


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find PathArgs definition + any doc comment for `global`
rg -n "pub struct PathArgs" -S apps/dustfril-cli/src
rg -n "pub\\s+global:\\s+bool" -S apps/dustfril-cli/src
rg -n "global\\s*:\\s*bool|--global" -S apps/dustfril-cli/src

# Show the PathArgs struct block with context
rg -n "pub struct PathArgs" -S apps/dustfril-cli/src -C 6

# Find all detector calls in the CLI
rg -n "detector::scan\\b|detector::scan_global\\b|detector::scan_workspace\\b" -S apps/dustfril-cli/src

# Also search other crates (in case old behavior lived outside CLI)
rg -n "detector::scan\\b|detector::scan_global\\b|detector::scan_workspace\\b" -S . | head -n 80

Repository: FrilLab/dustfril

Length of output: 1827


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the CLI file that defines PathArgs and print it (or a bounded range)
# (Using fd to find likely file names, then cat with line bounds.)
path_args_file="$(fd -t f -g '*PathArgs*' apps/dustfril-cli/src | head -n 1 || true)"
echo "PathArgs file guess: ${path_args_file:-<none>}"

# If we didn't find it by name, derive from rg output by locating the file directly:
# (fallback) list matching rust files and then dump those small ones partially.
files="$(rg -l "pub struct PathArgs" -S apps/dustfril-cli/src || true)"
echo "$files"

for f in $files; do
  echo "---- $f (first 120 lines) ----"
  sed -n '1,120p' "$f"
done

Repository: FrilLab/dustfril

Length of output: 1010


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate detector module entry points in dustfril_core and print relevant parts
rg -n "pub fn scan\\b|pub fn scan_global\\b|pub fn scan_workspace\\b" -S . --glob '*detector*' --glob '*scan*.rs' --glob '*mod*.rs' | head -n 80

Repository: FrilLab/dustfril

Length of output: 330


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect CLI command routing
sed -n '1,200p' apps/dustfril-cli/src/commands/scan.rs
sed -n '1,220p' apps/dustfril-cli/src/commands/analyze.rs
sed -n '1,220p' apps/dustfril-cli/src/commands/clean.rs

# Find whether combined detector::scan(...) is used anywhere
rg -n "detector::scan\\(" -S . 

# Search docs/help for --global wording
rg -n "--global" -S . 
rg -n "Scan the entire system" -S .

Repository: FrilLab/dustfril

Length of output: 5937


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect dustfril_core detector scan implementation
sed -n '1,140p' crates/dustfril-core/src/detector/scan.rs

# Inspect how detector::scan is re-exported (if needed)
rg -n "mod scan|pub mod scan|pub use .*scan" crates/dustfril-core/src/detector -S

# Inspect registry + git detection logic (to see whether they discover projects too)
fd -t f 'registry*.rs|git*.rs' crates/dustfril-core/src/detector --exec sed -n '1,220p'

# Inspect path resolution/validation in CLI shared module
rg -n "fn resolve_path|fn validate_path" -S apps/dustfril-cli/src/shared/path.rs apps/dustfril-cli/src/shared -S
fd -t f 'path.rs' apps/dustfril-cli/src/shared --exec sed -n '1,220p'

Repository: FrilLab/dustfril

Length of output: 3395


🏁 Script executed:

#!/bin/bash
set -euo pipefail
# Also search for README/help text about --global semantics
rg -n "dustfril .*--global|--global|Scan the entire system|Scan the entire" -S .

Repository: FrilLab/dustfril

Length of output: 158


🏁 Script executed:

#!/bin/bash
set -euo pipefail
# Search old combined usage of detector::scan( ... ) in CLI (might still exist elsewhere)
rg -n "detector::scan\\(" -S apps/dustfril-cli/src crates/dustfril-core/src

Repository: FrilLab/dustfril

Length of output: 42


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Repo-wide: is detector::scan(...) called anywhere?
rg -n "detector::scan\\(" -S .

# Repo-wide: where is --global mentioned?
rg -n "(--global\\b|Scan the entire system)" -S .

Repository: FrilLab/dustfril

Length of output: 42


Fix --global vs workspace semantics to match flag documentation

  • --global currently dispatches to detector::scan_global() only (Cargo registry + git under $HOME/.cargo), while non---global dispatches to detector::scan_workspace(&path) only (per-project artifacts discovered under the provided path).
  • PathArgs.global says “Scan the entire system…”, but scan_global() doesn’t discover Cargo projects across the system; project artifacts only appear when --global is false.
  • If --global is meant to include both project + global artifacts, route to detector::scan(&path); otherwise update the docstring to reflect “global registry/git artifacts only”.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/dustfril-cli/src/commands/analyze.rs` around lines 65 - 69, The flag
semantics are inverted: when args.global is true the code currently calls
detector::scan_global() which only finds registry/git artifacts under
$HOME/.cargo and omits per-project artifacts; to fix, either (A) make the global
flag include both project and global artifacts by calling detector::scan(&path)
when args.global is true (so use detector::scan(&path) instead of
detector::scan_global()), or (B) if global should remain registry-only, update
the PathArgs.global docstring to explicitly state “global registry/git artifacts
only” and keep calling detector::scan_global(); update the call site that now
chooses between detector::scan_global() and detector::scan_workspace(&path)
accordingly to reflect the chosen behavior.


let analysis_result = analyzer::analyze(scan_result);

Expand Down
24 changes: 15 additions & 9 deletions apps/dustfril-cli/src/commands/clean.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
use std::path::Path;

use dustfril_core::{
analyzer, cleaner, detector,
models::{CleanupPlan, CleanupResult},
};

use crate::{cli::CleanArgs, shared::path::resolve_path};

// dry-run
pub fn dry_run() {
let plan = build_cleanup_plan();
pub fn dry_run(args: &CleanArgs) {
let plan = build_cleanup_plan(args);

if plan.candidates.is_empty() {
println!("No cleanup candidates found.");
Expand All @@ -22,8 +22,14 @@ pub fn dry_run() {

use std::io::{self, Write};

fn build_cleanup_plan() -> CleanupPlan {
let scan_result = detector::scan(Path::new("."));
fn build_cleanup_plan(args: &CleanArgs) -> CleanupPlan {
let path = resolve_path(&args.path_args.path);

let scan_result = if args.path_args.global {
detector::scan_global()
} else {
detector::scan_workspace(&path)
};
Comment on lines +25 to +32
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Inconsistent path validation in clean.

Unlike scan::execute and analyze::execute, build_cleanup_plan resolves the path but never calls validate_path. An invalid/non-existent path silently yields an empty plan ("No cleanup candidates found.") instead of the clearer validation error the other commands emit. Add the same validate_path guard for consistent UX.

🛠️ Proposed fix
-use crate::{cli::CleanArgs, shared::path::resolve_path};
+use crate::{
+    cli::CleanArgs,
+    shared::path::{resolve_path, validate_path},
+};

Note: build_cleanup_plan returns CleanupPlan, so propagating an early-return on invalid paths will require either changing its signature (e.g. to Option<CleanupPlan>) or validating in dry_run/execute before building the plan.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/dustfril-cli/src/commands/clean.rs` around lines 25 - 32,
build_cleanup_plan currently resolves the path but never calls validate_path,
causing invalid/non-existent paths to silently produce an empty CleanupPlan;
either (A) add a validate_path(&path) guard before scanning inside
build_cleanup_plan and change its signature to return Option<CleanupPlan> or
Result<CleanupPlan, Error> so you can early-return on invalid paths, or (B) keep
build_cleanup_plan as-is and call validate_path(&path) in the callers (dry_run
and execute) before invoking build_cleanup_plan so the same validation/error
behavior is enforced as in scan::execute and analyze::execute; update references
to CleanArgs, resolve_path, and CleanupPlan accordingly to propagate the
validation outcome.


let analysis = analyzer::analyze(scan_result);

Expand Down Expand Up @@ -71,7 +77,7 @@ fn print_cleanup_result(result: &CleanupResult) {

println!("Failed: {}", result.failed_paths.len());

println!("Freed: {}", analyzer::format_size(result.freed_size_bytes,));
println!("Freed: {}", analyzer::format_size(result.freed_size_bytes));

if !result.deleted_paths.is_empty() {
println!("Deleted\n");
Expand All @@ -84,8 +90,8 @@ fn print_cleanup_result(result: &CleanupResult) {
}
}

pub fn execute() {
let plan = build_cleanup_plan();
pub fn execute(args: &CleanArgs) {
let plan = build_cleanup_plan(args);

if plan.candidates.is_empty() {
println!("No cleanup candidates found.");
Expand Down
21 changes: 17 additions & 4 deletions apps/dustfril-cli/src/commands/scan.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
use std::path::Path;

use dustfril_core::detector;

pub fn execute() {
let result = detector::scan(Path::new("."));
use crate::{
cli::PathArgs,
shared::path::{resolve_path, validate_path},
};

pub fn execute(args: PathArgs) {
let path = resolve_path(&args.path);

if !validate_path(&path) {
return;
}

let result = if args.global {
detector::scan_global()
} else {
detector::scan_workspace(&path)
};

if result.artifacts.is_empty() {
println!("No Rust artifacts found.");
Expand Down
13 changes: 7 additions & 6 deletions apps/dustfril-cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
mod cli;
mod commands;
mod shared;

use clap::Parser;

Expand All @@ -9,19 +10,19 @@ fn main() {
let cli = Cli::parse();

match cli.command {
Commands::Scan => {
commands::scan::execute();
Commands::Scan(args) => {
commands::scan::execute(args);
}

Commands::Analyze => {
commands::analyze::execute();
Commands::Analyze(args) => {
commands::analyze::execute(args);
}

Commands::Clean(args) => {
if args.dry_run {
commands::clean::dry_run();
commands::clean::dry_run(&args);
} else {
commands::clean::execute();
commands::clean::execute(&args);
}
}
}
Expand Down
1 change: 1 addition & 0 deletions apps/dustfril-cli/src/shared/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub mod path;
16 changes: 16 additions & 0 deletions apps/dustfril-cli/src/shared/path.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
use std::path::{Path, PathBuf};

pub fn validate_path(path: &Path) -> bool {
if !path.exists() {
eprintln!("Path does not exist: {}", path.display());

return false;
}

true
}
Comment on lines +3 to +11
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

❓ Verification inconclusive

🏁 Script executed:

#!/bin/bash
ast-grep --pattern 'pub fn scan_workspace($$$) { $$$ }'
rg -nP '\bscan_workspace\b' -C3

Repository: FrilLab/dustfril

Length of output: 3776


Reject non-directory paths in validate_path.

validate_path returns true for any existing filesystem entry (including regular files). The CLI then passes that path into detector::scan_workspace, which forwards it to project::find_projects(root), so failing fast on non-directories would prevent passing an invalid “workspace root” to the scanner.

🛡️ Proposed fix to also require a directory
 pub fn validate_path(path: &Path) -> bool {
     if !path.exists() {
         eprintln!("Path does not exist: {}", path.display());

         return false;
     }

+    if !path.is_dir() {
+        eprintln!("Path is not a directory: {}", path.display());
+
+        return false;
+    }
+
     true
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub fn validate_path(path: &Path) -> bool {
if !path.exists() {
eprintln!("Path does not exist: {}", path.display());
return false;
}
true
}
pub fn validate_path(path: &Path) -> bool {
if !path.exists() {
eprintln!("Path does not exist: {}", path.display());
return false;
}
if !path.is_dir() {
eprintln!("Path is not a directory: {}", path.display());
return false;
}
true
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/dustfril-cli/src/shared/path.rs` around lines 3 - 11, The validate_path
function currently accepts any existing filesystem entry; change it to also
require a directory by checking path.is_dir() inside validate_path and returning
false (with a clear eprintln! message like "Path is not a directory: {}") when
it is not a directory so detector::scan_workspace and downstream
project::find_projects(root) never receive a non-directory workspace root.


pub fn resolve_path(path: &Option<PathBuf>) -> PathBuf {
path.clone()
.unwrap_or_else(|| std::env::current_dir().expect("Failed to get current directory"))
}
6 changes: 0 additions & 6 deletions crates/dustfril-core/src/detector/cargo_project.rs

This file was deleted.

Empty file.
Empty file.
Empty file.
Empty file.
8 changes: 3 additions & 5 deletions crates/dustfril-core/src/detector/mod.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
mod cargo_project;
mod git;
mod registry;
mod project;
mod rust;
mod scan;
mod target;

#[cfg(test)]
mod tests;

pub use scan::{scan, scan_global, scan_project};
pub use scan::{scan, scan_global, scan_project, scan_workspace};
Empty file.
Empty file.
Empty file.
53 changes: 53 additions & 0 deletions crates/dustfril-core/src/detector/project.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
use std::{
fs,
path::{Path, PathBuf},
};

// #[derive(Debug, Clone, Copy, PartialEq, Eq)]
// pub enum Ecosystem {
// Rust,
// Node,
// Java,
// }

#[derive(Debug, Clone)]
pub struct Project {
pub root: PathBuf,
// pub ecosystem: Ecosystem,
}

pub fn find_projects(root: &Path) -> Vec<Project> {
let mut projects = Vec::new();

visit(root, &mut projects);

projects
}

/// Cargo project detection and artifact scanning.
pub fn is_cargo_project(root: &Path) -> bool {
root.join("Cargo.toml").is_file()
}

fn visit(dir: &Path, projects: &mut Vec<Project>) {
if is_cargo_project(dir) {
projects.push(Project {
root: dir.to_path_buf(),
// ecosystem: Ecosystem::Rust,
});

return;
}
Comment on lines +32 to +40
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Confirm that stopping descent at the first Cargo.toml is intended.

visit returns as soon as a Cargo.toml is found, so nested projects below a detected root are never discovered. This is correct for a Cargo workspace (members share the root target/), but a directory that itself contains a Cargo.toml and also holds unrelated nested Cargo projects would have those nested projects (and their own target/ dirs) silently skipped. Please confirm this matches the intended discovery semantics for issue #48.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/dustfril-core/src/detector/project.rs` around lines 32 - 40, The
current visit function returns immediately when is_cargo_project(dir) is true,
which prevents discovering nested Cargo projects; change the logic in visit so
it still pushes Project { root: dir... } but does not return
unconditionally—only stop descent if the directory is a Cargo workspace root
(implement or use a helper like is_cargo_workspace(dir) that checks Cargo.toml
for a [workspace] table or workspace members). In practice: update visit to call
is_cargo_project(dir) to add the Project, then call is_cargo_workspace(dir) and
only return early when that helper is true; otherwise continue recursing into
subdirectories to detect nested projects. Ensure references to Project, visit,
is_cargo_project, and the new is_cargo_workspace helper are updated accordingly.


let Ok(entries) = fs::read_dir(dir) else {
return;
};

for entry in entries.flatten() {
let path = entry.path();

if path.is_dir() {
visit(&path, projects);
}
}
Comment on lines +46 to +52
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does Rust std::fs::DirEntry::file_type FileType::is_dir return false for symlinks (i.e. it does not follow the link)?

💡 Result:

Yes, std::fs::DirEntry::file_type does not follow symlinks [1][2]. When called on a DirEntry that points to a symbolic link, it returns the file type of the link itself, rather than the file type of the link's target [1][2]. Because FileType::is_dir is mutually exclusive to FileType::is_symlink [3][4], it will return false if the entry is a symbolic link, even if that link points to a directory [3][4]. In summary: 1. DirEntry::file_type returns the file type of the symlink itself, not the target [1][2]. 2. FileType::is_dir checks if the file type is a directory [3]. Since a symlink is classified as a symlink (and not a directory) by the FileType methods, is_dir returns false for symlinks [3][4]. 3. To determine if a symlink points to a directory, you would typically need to check if the entry is a symlink, and if so, potentially resolve the path or use alternative methods to inspect the target [5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="crates/dustfril-core/src/detector/project.rs"
echo "---- $FILE ----"
nl -ba "$FILE" | sed -n '1,140p'

echo "---- search for is_dir/metadata usage in file ----"
rg -n "is_dir\(|file_type\(|symlink|read_link|follow" "$FILE" || true

echo "---- locate visit() definition and how recursion is handled ----"
rg -n "fn visit|visit\(" "$FILE"

Repository: FrilLab/dustfril

Length of output: 176


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="crates/dustfril-core/src/detector/project.rs"
echo "---- $FILE (first 200 lines with line numbers) ----"
cat -n "$FILE" | sed -n '1,220p'

echo "---- search for is_dir/file_type/symlink usage in file ----"
rg -n "is_dir\(|file_type\(|symlink|read_link|follow" "$FILE" || true

echo "---- locate visit() definition and its recursion ----"
rg -n "fn visit|visit\(" "$FILE" || true

Repository: FrilLab/dustfril

Length of output: 1784


🌐 Web query:

Rust std::path::Path::is_dir follow symlinks? uses metadata vs symlink_metadata

💡 Result:

Rust’s std::path::Path::is_dir does follow symlinks. In the Rust docs for Path::is_dir, it states that the function “will traverse symbolic links to query information about the destination file” and returns true when the path exists and is pointing at a directory [1]. By contrast, std::path::Path::symlink_metadata queries metadata “without following symlinks” [1]. If you want to test whether a symlink itself (not its target) is a directory, the usual approach is: - call symlink_metadata, then inspect whether the resulting metadata represents a symlink (via Metadata::is_symlink / file_type), rather than using is_dir which follows the link [1][2]. Summary: - Path::is_dir -> follows symlinks (checks target) [1] - Path::symlink_metadata -> does not follow symlinks (checks link itself) [1]

Citations:


Avoid symlink-recursion in system-wide project scanning
visit recurses via path.is_dir(), and Path::is_dir traverses symlinks; a symlinked directory cycle (e.g., pointing to an ancestor) can cause unbounded recursion/stack overflow.
Skip symlinks by checking the DirEntry’s own file type instead of following the link.

🛡️ Proposed fix to skip symlinks
     for entry in entries.flatten() {
-        let path = entry.path();
-
-        if path.is_dir() {
-            visit(&path, projects);
-        }
+        let Ok(file_type) = entry.file_type() else {
+            continue;
+        };
+
+        if file_type.is_dir() {
+            visit(&entry.path(), projects);
+        }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
visit(&path, projects);
}
}
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
visit(&entry.path(), projects);
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/dustfril-core/src/detector/project.rs` around lines 46 - 52, The
recursion in visit currently uses path.is_dir() which follows symlinks and can
enter cycles; change the check to use the DirEntry's file_type to detect
directories without following symlinks (e.g., call entry.file_type() and test
file_type.is_dir()) and skip entries where file_type.is_symlink() or where
file_type indicates not a dir before recursing into visit(&path, projects);
update the loop that iterates entries.flatten() to obtain and inspect
entry.file_type() and only recurse for true directory file types.

}
3 changes: 3 additions & 0 deletions crates/dustfril-core/src/detector/rust/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pub mod git;
pub mod registry;
pub mod target;
26 changes: 23 additions & 3 deletions crates/dustfril-core/src/detector/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@ use std::path::Path;

use crate::models::ScanResult;

use super::{cargo_project, git, registry, target};
use super::{
project,
rust::{git, registry, target},
};

// Scan a single Rust project for artifacts.
pub fn scan_project(root: &Path) -> ScanResult {
let mut result = ScanResult::default();

if !cargo_project::is_cargo_project(root) {
if !project::is_cargo_project(root) {
return result;
}

Expand All @@ -18,6 +22,22 @@ pub fn scan_project(root: &Path) -> ScanResult {
result
}

// Recursively scan for Rust projects and their artifacts.
pub fn scan_workspace(root: &Path) -> ScanResult {
let mut result = ScanResult::default();

let projects = project::find_projects(root);

for project in projects {
let project_result = scan_project(&project.root);

result.artifacts.extend(project_result.artifacts);
}

result
}

// Global artifacts that are not tied to a specific project, like Cargo registry and Git repositories.
pub fn scan_global() -> ScanResult {
let mut result = ScanResult::default();

Expand All @@ -33,7 +53,7 @@ pub fn scan_global() -> ScanResult {
}

pub fn scan(root: &Path) -> ScanResult {
let mut result = scan_project(root);
let mut result = scan_workspace(root);

result.artifacts.extend(scan_global().artifacts);

Expand Down
Loading