From a250c5e7ad920a3bd64c7de6242c5be090b841b2 Mon Sep 17 00:00:00 2001 From: Ibrahim Rahhal Date: Sun, 21 Dec 2025 13:01:45 +0300 Subject: [PATCH] Supported Targeted scans & Project Name Param --- src/main.rs | 17 +- src/scanners/blast.rs | 126 +++++++---- src/targets.rs | 481 ++++++++++++++++++++++++++++++++++++++++++ src/utils/generic.rs | 209 ++++++++++++------ src/utils/terminal.rs | 2 + 5 files changed, 729 insertions(+), 106 deletions(-) create mode 100644 src/targets.rs diff --git a/src/main.rs b/src/main.rs index b95ceca..8ce0eca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ mod utils { pub mod generic; pub mod api; } +mod targets; use std::str::FromStr; use clap::{Parser, Subcommand, CommandFactory}; @@ -86,6 +87,18 @@ enum Commands { #[arg(short, long, help = "Output the result to a file. you can use the out_format option to specify the format of the output file.")] out_file: Option, + + #[arg( + long, + help = "Specify specific files, directories, glob patterns, or git selectors to scan. Accepts comma-separated values. Examples: 'src/,pyproject.toml', 'src/**/*.py', 'git:diff=origin/main...HEAD', 'git:staged', 'git:untracked', or '-' to read from stdin (newline-delimited). Use '-0' for NUL-delimited stdin." + )] + target: Option, + + #[arg( + long, + help = "The name of the Corgea project. Defaults to git repository name if found, otherwise to the current directory name." + )] + project_name: Option, }, /// Wait for the latest in progress scan Wait { @@ -238,7 +251,7 @@ fn main() { } } } - Some(Commands::Scan { scanner , fail_on, fail, only_uncommitted, scan_type, policy, out_format, out_file }) => { + Some(Commands::Scan { scanner , fail_on, fail, only_uncommitted, scan_type, policy, out_format, out_file, target, project_name }) => { verify_token_and_exit_when_fail(&corgea_config); if let Some(level) = fail_on { if *scanner != Scanner::Blast { @@ -321,7 +334,7 @@ fn main() { match scanner { Scanner::Snyk => scan::run_snyk(&corgea_config), Scanner::Semgrep => scan::run_semgrep(&corgea_config), - Scanner::Blast => scanners::blast::run(&corgea_config, fail_on.clone(), fail, only_uncommitted, scan_type.clone(), policy.clone(), out_format.clone(), out_file.clone()) + Scanner::Blast => scanners::blast::run(&corgea_config, fail_on.clone(), fail, only_uncommitted, scan_type.clone(), policy.clone(), out_format.clone(), out_file.clone(), target.clone(), project_name.clone()) } } Some(Commands::Wait { scan_id }) => { diff --git a/src/scanners/blast.rs b/src/scanners/blast.rs index b04e14b..df44855 100644 --- a/src/scanners/blast.rs +++ b/src/scanners/blast.rs @@ -1,5 +1,6 @@ use crate::utils; use crate::config::Config; +use crate::targets; use std::collections::HashMap; use std::sync::{Arc, Mutex}; use std::error::Error; @@ -19,7 +20,15 @@ pub fn run( policy: Option, out_format: Option, out_file: Option, + target: Option, + project_name: Option, ) { + // Validate that only_uncommitted and target are not used together + if *only_uncommitted && target.is_some() { + eprintln!("--only_uncommitted and --target cannot be used together."); + std::process::exit(1); + } + if *only_uncommitted { match utils::generic::is_git_repo("./") { Ok(false) => { @@ -48,7 +57,7 @@ pub fn run( println!("\n\n"); let temp_dir = env::temp_dir().join(format!("corgea/tmp/{}", Uuid::new_v4())); fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); - let project_name = utils::generic::get_current_working_directory().unwrap_or("unknown".to_string()); + let project_name = utils::generic::determine_project_name(project_name.as_deref()); let zip_path = format!("{}/{}.zip", temp_dir.display(), project_name); let repo_info = match utils::generic::get_repo_info("./") { Ok(info) => info, @@ -73,61 +82,98 @@ pub fn run( utils::terminal::show_loading_message("Packaging your project... ([T]s)", stop_signal_clone); }); - if *only_uncommitted { - match utils::generic::get_untracked_and_modified_files("./") { - Ok(files) => { - let files_to_zip: Vec<(std::path::PathBuf, std::path::PathBuf)> = files - .iter() - .map(|file| (std::path::PathBuf::from(file), std::path::PathBuf::from(file))) - .collect(); - println!("\rFiles to be submitted for partial scan:\n"); - for (index, (_, original)) in files_to_zip.iter().enumerate() { - println!("{}: {}", index + 1, original.display()); - } - print!("\n\n"); - if files_to_zip.is_empty() { + let target_str: Option<&str> = if *only_uncommitted { + Some("git:staged,git:modified,git:untracked") + } else { + target.as_deref() + }; + + if let Some(target_value) = target_str { + match targets::resolve_targets(target_value) { + Ok(result) => { + if result.files.is_empty() { *stop_signal.lock().unwrap() = true; + let _ = packaging_thread.join(); print!("\r{}", utils::terminal::set_text_color("", utils::terminal::TerminalColor::Reset)); - eprintln!( - "\n\nOops! It seems there are no scannable uncommitted changes in your project.\nYou may have uncommitted changes, but none match the types of files we can scan.\n\n" - ); + eprintln!("\n\nError: target resolved to zero files.\n"); + eprintln!("Target value: {}\n", target_value); + eprintln!("Segment results:"); + for segment_result in &result.segments { + if let Some(ref error) = segment_result.error { + eprintln!(" {}: ERROR - {}", segment_result.segment, error); + } else { + eprintln!(" {}: {} matches", segment_result.segment, segment_result.matches); + } + } + eprintln!("\nPlease check your target specification and try again.\n"); std::process::exit(1); } - match utils::generic::create_zip_from_list_of_files(files_to_zip, &zip_path, None) { - Ok(_) => {}, - Err(e) => { - *stop_signal.lock().unwrap() = true; - print!("\r{}", utils::terminal::set_text_color("", utils::terminal::TerminalColor::Reset)); - eprintln!( - "\n\nUh-oh! We couldn't package your project at '{}'.\nThis might be due to insufficient permissions, invalid file paths, or a file system error.\nPlease check the directory and try again.\nError details:\n{}\n\n", - zip_path, e - ); - std::process::exit(1); + + let file_count = result.files.len(); + if *only_uncommitted { + println!("\rFiles to be submitted for partial scan:\n"); + for (index, file) in result.files.iter().enumerate() { + if let Ok(relative) = file.strip_prefix(std::env::current_dir().unwrap_or_default()) { + println!("{}: {}", index + 1, relative.display()); + } else { + println!("{}: {}", index + 1, file.display()); + } + } + println!(); + } else { + println!("Scanning {} files (target mode)", file_count); + + let display_count = std::cmp::min(20, file_count); + for file in result.files.iter().take(display_count) { + if let Ok(relative) = file.strip_prefix(std::env::current_dir().unwrap_or_default()) { + println!(" {}", relative.display()); + } else { + println!(" {}", file.display()); + } } + if file_count > display_count { + println!(" (+{} more)", file_count - display_count); + } + println!(); } - }, + } Err(e) => { *stop_signal.lock().unwrap() = true; + let _ = packaging_thread.join(); print!("\r{}", utils::terminal::set_text_color("", utils::terminal::TerminalColor::Reset)); - eprintln!( - "\n\nFailed to retrieve untracked and modified files.\nError details:\n{}\n\n", - e - ); + eprintln!("\n\nError resolving targets: {}\n", e); std::process::exit(1); } } - } else { - match utils::generic::create_zip_from_filtered_files(".", None, &zip_path) { - Ok(_) => { }, - Err(e) => { + } + + match utils::generic::create_zip_from_target(target_str, &zip_path, None) { + Ok(added_files) => { + if added_files.is_empty() { *stop_signal.lock().unwrap() = true; + let _ = packaging_thread.join(); print!("\r{}", utils::terminal::set_text_color("", utils::terminal::TerminalColor::Reset)); - eprintln!( - "\n\nUh-oh! We couldn't package your project at '{}'.\nThis might be due to insufficient permissions, invalid file paths, or a file system error.\nPlease check the directory and try again.\nError details:\n{}\n\n", - zip_path, e - ); + if *only_uncommitted { + eprintln!( + "\n\nOops! It seems there are no scannable uncommitted changes in your project.\nYou may have uncommitted changes, but none match the types of files we can scan.\n\n" + ); + } else { + eprintln!( + "\n\nOops! No valid files found to scan after filtering.\n\n" + ); + } std::process::exit(1); } + }, + Err(e) => { + *stop_signal.lock().unwrap() = true; + let _ = packaging_thread.join(); + print!("\r{}", utils::terminal::set_text_color("", utils::terminal::TerminalColor::Reset)); + eprintln!( + "\n\nUh-oh! We couldn't package your project at '{}'.\nThis might be due to insufficient permissions, invalid file paths, or a file system error.\nPlease check the directory and try again.\nError details:\n{}\n\n", + zip_path, e + ); + std::process::exit(1); } } *stop_signal.lock().unwrap() = true; diff --git a/src/targets.rs b/src/targets.rs new file mode 100644 index 0000000..81f2d47 --- /dev/null +++ b/src/targets.rs @@ -0,0 +1,481 @@ +use std::collections::HashSet; +use std::io::{self, BufRead, Read}; +use std::path::{Path, PathBuf}; +use globset::{Glob, GlobSetBuilder}; +use ignore::WalkBuilder; +use git2::{Repository, StatusOptions, Delta}; + +#[derive(Debug)] +pub struct TargetResolutionResult { + pub files: Vec, + pub segments: Vec, +} + +#[derive(Debug)] +pub struct TargetSegmentResult { + pub segment: String, + pub matches: usize, + pub error: Option, +} + +pub fn resolve_targets(target_value: &str) -> Result { + let segments: Vec = target_value + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + if segments.is_empty() { + return Err("Target value cannot be empty".to_string()); + } + + if segments.len() > 1 { + for segment in &segments { + if segment == "-" || segment == "-0" { + return Err(format!( + "Stdin mode ('{}') cannot be combined with other targets. It must be the only segment.", + segment + )); + } + } + } + + let mut all_files = Vec::new(); + let mut seen_files = HashSet::new(); + let mut segment_results = Vec::new(); + + let repo_root = find_repo_root()?; + + for segment in &segments { + match resolve_segment(segment, &repo_root) { + Ok(result) => { + segment_results.push(TargetSegmentResult { + segment: segment.clone(), + matches: result.len(), + error: None, + }); + + for file in result { + match normalize_path(&file, &repo_root) { + Ok(normalized) => { + if seen_files.insert(normalized.clone()) { + all_files.push(normalized); + } + } + Err(e) => { + segment_results.push(TargetSegmentResult { + segment: segment.clone(), + matches: 0, + error: Some(format!("Failed to normalize path {}: {}", file.display(), e)), + }); + } + } + } + } + Err(e) => { + segment_results.push(TargetSegmentResult { + segment: segment.clone(), + matches: 0, + error: Some(e), + }); + } + } + } + + let errors: Vec<_> = segment_results + .iter() + .filter_map(|r| r.error.as_ref().map(|e| format!("{}: {}", r.segment, e))) + .collect(); + + if !errors.is_empty() { + return Err(format!( + "Errors resolving targets:\n{}", + errors.join("\n") + )); + } + + Ok(TargetResolutionResult { + files: all_files, + segments: segment_results, + }) +} + +fn resolve_segment(segment: &str, repo_root: &Path) -> Result, String> { + if segment == "-" { + return read_stdin_files(false); + } + if segment == "-0" { + return read_stdin_files(true); + } + + if segment.starts_with("git:") { + return resolve_git_selector(segment, repo_root); + } + + let path = Path::new(segment); + + let full_path = if path.is_absolute() { + path.to_path_buf() + } else { + repo_root.join(path) + }; + + if !full_path.exists() { + return resolve_glob(segment, repo_root); + } + + if full_path.is_file() { + Ok(vec![full_path]) + } else if full_path.is_dir() { + resolve_directory(&full_path, repo_root) + } else { + resolve_glob(segment, repo_root) + } +} + +fn read_stdin_files(nul_delimited: bool) -> Result, String> { + let stdin = io::stdin(); + let mut files = Vec::new(); + let repo_root = find_repo_root()?; + + if nul_delimited { + let mut buffer = Vec::new(); + stdin.lock().read_to_end(&mut buffer).map_err(|e| { + format!("Failed to read from stdin: {}", e) + })?; + + for part in buffer.split(|&b| b == 0) { + if part.is_empty() { + continue; + } + let path_str = String::from_utf8_lossy(part).trim().to_string(); + if !path_str.is_empty() { + let path = Path::new(&path_str); + let full_path = if path.is_absolute() { + path.to_path_buf() + } else { + repo_root.join(path) + }; + if full_path.exists() && full_path.is_file() { + files.push(full_path); + } + } + } + } else { + for line in stdin.lock().lines() { + let line = line.map_err(|e| format!("Failed to read from stdin: {}", e))?; + let path_str = line.trim(); + if path_str.is_empty() { + continue; + } + let path = Path::new(path_str); + let full_path = if path.is_absolute() { + path.to_path_buf() + } else { + repo_root.join(path) + }; + if full_path.exists() && full_path.is_file() { + files.push(full_path); + } + } + } + + Ok(files) +} + +fn resolve_git_selector(selector: &str, repo_root: &Path) -> Result, String> { + if !is_git_repo(repo_root) { + return Err(format!( + "Git selector '{}' requires a git repository, but no git repository was found", + selector + )); + } + + let files = if selector == "git:staged" { + get_git_staged_files(repo_root)? + } else if selector == "git:untracked" { + get_git_untracked_files(repo_root)? + } else if selector == "git:modified" { + get_git_modified_files(repo_root)? + } else if selector.starts_with("git:diff=") { + let range = selector.strip_prefix("git:diff=").unwrap(); + get_git_diff_files(repo_root, range)? + } else { + return Err(format!("Invalid git selector: {}. Valid options are: git:staged, git:untracked, git:modified, git:diff=", selector)); + }; + + let mut result = Vec::new(); + for file in files { + let full_path = repo_root.join(&file); + if full_path.exists() && full_path.is_file() { + result.push(full_path); + } + } + + Ok(result) +} + +fn get_git_staged_files(repo_root: &Path) -> Result, String> { + let repo = Repository::open(repo_root) + .map_err(|e| format!("Failed to open git repository: {}", e))?; + + let mut index = repo.index() + .map_err(|e| format!("Failed to get index: {}", e))?; + + let head_tree = repo.head() + .ok() + .and_then(|head| head.peel_to_tree().ok()); + + let index_tree_id = index.write_tree() + .map_err(|e| format!("Failed to write index tree: {}", e))?; + let index_tree = repo.find_tree(index_tree_id) + .map_err(|e| format!("Failed to find index tree: {}", e))?; + + let diff = repo.diff_tree_to_tree( + head_tree.as_ref(), + Some(&index_tree), + None + ).map_err(|e| format!("Failed to create diff: {}", e))?; + + let mut files = Vec::new(); + diff.foreach( + &mut |delta, _| { + if let Some(path) = delta.new_file().path() { + match delta.status() { + Delta::Added | Delta::Copied | Delta::Modified | Delta::Renamed => { + files.push(PathBuf::from(path)); + } + _ => {} + } + } + true + }, + None, + None, + None, + ).map_err(|e| format!("Failed to iterate diff: {}", e))?; + + Ok(files) +} + +fn get_git_untracked_files(repo_root: &Path) -> Result, String> { + let repo = Repository::open(repo_root) + .map_err(|e| format!("Failed to open git repository: {}", e))?; + + let mut opts = StatusOptions::new(); + opts.include_untracked(true); + opts.exclude_submodules(true); + opts.include_ignored(false); + + let statuses = repo.statuses(Some(&mut opts)) + .map_err(|e| format!("Failed to get statuses: {}", e))?; + + let mut files = Vec::new(); + for entry in statuses.iter() { + let status = entry.status(); + if status.is_wt_new() && !status.is_ignored() { + if let Some(path) = entry.path() { + files.push(PathBuf::from(path)); + } + } + } + + Ok(files) +} + +fn get_git_modified_files(repo_root: &Path) -> Result, String> { + let repo = Repository::open(repo_root) + .map_err(|e| format!("Failed to open git repository: {}", e))?; + + let head_tree = repo.head() + .ok() + .and_then(|head| head.peel_to_tree().ok()); + + let diff = repo.diff_tree_to_workdir( + head_tree.as_ref(), + None + ).map_err(|e| format!("Failed to create diff: {}", e))?; + + let mut files = Vec::new(); + diff.foreach( + &mut |delta, _| { + if let Some(path) = delta.new_file().path() { + match delta.status() { + Delta::Added | Delta::Copied | Delta::Modified | Delta::Renamed => { + files.push(PathBuf::from(path)); + } + _ => {} + } + } + true + }, + None, + None, + None, + ).map_err(|e| format!("Failed to iterate diff: {}", e))?; + + Ok(files) +} + +fn get_git_diff_files(repo_root: &Path, range: &str) -> Result, String> { + let repo = Repository::open(repo_root) + .map_err(|e| format!("Failed to open git repository: {}", e))?; + + let parts: Vec<&str> = range.split("...").collect(); + let (old_ref, new_ref) = if parts.len() == 2 { + (parts[0].trim(), parts[1].trim()) + } else { + let parts: Vec<&str> = range.split("..").collect(); + if parts.len() == 2 { + (parts[0].trim(), parts[1].trim()) + } else { + return Err(format!("Invalid diff range format: {}. Expected format: 'old..new' or 'old...new'", range)); + } + }; + + let old_commit = if old_ref.is_empty() { + None + } else { + Some(repo.revparse_single(old_ref) + .map_err(|e| format!("Failed to resolve reference '{}': {}", old_ref, e))? + .id()) + }; + + let new_commit = if new_ref.is_empty() { + repo.head() + .map_err(|e| format!("Failed to get HEAD: {}", e))? + .target() + .ok_or_else(|| format!("HEAD is not a direct reference"))? + } else { + repo.revparse_single(new_ref) + .map_err(|e| format!("Failed to resolve reference '{}': {}", new_ref, e))? + .id() + }; + + let old_tree = if let Some(old_id) = old_commit { + Some(repo.find_commit(old_id) + .map_err(|e| format!("Failed to find commit: {}", e))? + .tree() + .map_err(|e| format!("Failed to get tree: {}", e))?) + } else { + None + }; + + let new_tree = repo.find_commit(new_commit) + .map_err(|e| format!("Failed to find commit: {}", e))? + .tree() + .map_err(|e| format!("Failed to get tree: {}", e))?; + + let diff = repo.diff_tree_to_tree( + old_tree.as_ref(), + Some(&new_tree), + None + ).map_err(|e| format!("Failed to create diff: {}", e))?; + + let mut files = Vec::new(); + diff.foreach( + &mut |delta, _| { + if let Some(path) = delta.new_file().path() { + match delta.status() { + Delta::Added | Delta::Copied | Delta::Modified | Delta::Renamed => { + files.push(PathBuf::from(path)); + } + _ => {} + } + } + true + }, + None, + None, + None, + ).map_err(|e| format!("Failed to iterate diff: {}", e))?; + + Ok(files) +} + +fn resolve_directory(dir: &Path, _repo_root: &Path) -> Result, String> { + let mut files = Vec::new(); + + let walker = WalkBuilder::new(dir) + .standard_filters(true) + .build(); + + for result in walker { + let entry = result.map_err(|e| format!("Error walking directory: {}", e))?; + let path = entry.path(); + + if path.is_file() { + files.push(path.to_path_buf()); + } + } + + Ok(files) +} + +fn resolve_glob(pattern: &str, repo_root: &Path) -> Result, String> { + let glob = Glob::new(pattern) + .map_err(|e| format!("Invalid glob pattern '{}': {}", pattern, e))?; + + let mut glob_builder = GlobSetBuilder::new(); + glob_builder.add(glob); + let glob_set = glob_builder.build() + .map_err(|e| format!("Failed to build glob set: {}", e))?; + + let mut files = Vec::new(); + + let walker = WalkBuilder::new(repo_root) + .standard_filters(true) + .build(); + + for result in walker { + let entry = result.map_err(|e| format!("Error walking directory: {}", e))?; + let path = entry.path(); + + if path.is_file() { + // Get relative path from repo root + if let Ok(relative) = path.strip_prefix(repo_root) { + if glob_set.is_match(relative) { + files.push(path.to_path_buf()); + } + } + } + } + + Ok(files) +} + +fn normalize_path(path: &Path, _repo_root: &Path) -> Result { + let abs_path = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map_err(|e| format!("Failed to get current directory: {}", e))? + .join(path) + .canonicalize() + .map_err(|e| format!("Failed to canonicalize path: {}", e))? + }; + + Ok(abs_path) +} + +fn find_repo_root() -> Result { + let current_dir = std::env::current_dir() + .map_err(|e| format!("Failed to get current directory: {}", e))?; + + match Repository::discover(¤t_dir) { + Ok(repo) => { + repo.workdir() + .map(|p| p.to_path_buf()) + .or_else(|| repo.path().parent().map(|p| p.to_path_buf())) + .ok_or_else(|| "Failed to determine repository root".to_string()) + } + Err(_) => { + Ok(current_dir) + } + } +} + +fn is_git_repo(dir: &Path) -> bool { + Repository::discover(dir).is_ok() +} + diff --git a/src/utils/generic.rs b/src/utils/generic.rs index 445df74..627ddda 100644 --- a/src/utils/generic.rs +++ b/src/utils/generic.rs @@ -1,11 +1,12 @@ use std::io; -use std::path::Path; +use std::path::{Path, PathBuf}; use zip::{write::FileOptions, ZipWriter}; use ignore::WalkBuilder; use globset::{GlobSetBuilder, Glob}; use std::fs::{self, File}; use std::env; use git2::Repository; +use crate::utils::terminal::{set_text_color, TerminalColor}; // Global exclude globs used across multiple functions const DEFAULT_EXCLUDE_GLOBS: &[&str] = &[ @@ -30,37 +31,16 @@ const DEFAULT_EXCLUDE_GLOBS: &[&str] = &[ "**/.idea/**", ]; -pub fn create_zip_from_filtered_files>( - directory: P, - exclude_globs: Option<&[&str]>, - output_zip: P, -) -> Result<(), Box> { - let directory = directory.as_ref(); - - let walker = WalkBuilder::new(directory) - .standard_filters(true) - .build(); - - let mut files_to_zip = Vec::new(); - - for result in walker { - let entry = result?; - let path = entry.path(); - - if path.is_file() || path.is_dir() { - let relative_path = path.strip_prefix(directory)?; - files_to_zip.push((path.to_path_buf(), relative_path.to_path_buf())); - } - } - - create_zip_from_list_of_files(files_to_zip, output_zip, exclude_globs) -} - -pub fn create_zip_from_list_of_files>( - files: Vec<(std::path::PathBuf, std::path::PathBuf)>, +/// Create a zip file from a target specification or full repository scan. +/// +/// - If `target` is `None`, performs a full repository scan (equivalent to scanning all files). +/// - If `target` is `Some(target_str)`, resolves the target using the targets module and creates zip from those files. +/// The target string can be a comma-separated list of files, directories, globs, or git selectors. +pub fn create_zip_from_target>( + target: Option<&str>, output_zip: P, exclude_globs: Option<&[&str]>, -) -> Result<(), Box> { +) -> Result, Box> { let exclude_globs = exclude_globs.unwrap_or(DEFAULT_EXCLUDE_GLOBS); let mut glob_builder = GlobSetBuilder::new(); @@ -69,6 +49,44 @@ pub fn create_zip_from_list_of_files>( } let glob_set = glob_builder.build()?; + let files_to_zip: Vec<(PathBuf, PathBuf)> = if let Some(target_str) = target { + let current_dir = env::current_dir()?; + let result = crate::targets::resolve_targets(target_str) + .map_err(|e| format!("Failed to resolve targets: {}", e))?; + + result.files + .iter() + .filter_map(|file| { + if !file.exists() || !file.is_file() { + return None; + } + match file.strip_prefix(¤t_dir) { + Ok(relative) => Some((file.clone(), relative.to_path_buf())), + Err(_) => { + Some((file.clone(), file.clone())) + } + } + }) + .collect() + } else { + let directory = Path::new("."); + let walker = WalkBuilder::new(directory) + .standard_filters(true) + .build(); + + let mut files = Vec::new(); + for result in walker { + let entry = result?; + let path = entry.path(); + + if path.is_file() || path.is_dir() { + let relative_path = path.strip_prefix(directory)?; + files.push((path.to_path_buf(), relative_path.to_path_buf())); + } + } + files + }; + let zip_file = File::create(output_zip.as_ref())?; let mut zip = ZipWriter::new(zip_file); @@ -76,52 +94,54 @@ pub fn create_zip_from_list_of_files>( .compression_method(zip::CompressionMethod::Deflated) .unix_permissions(0o755); - for (path, relative_path) in files { - if (path.is_file() || path.is_dir()) && !glob_set.is_match(&path) { + let mut added_files = Vec::new(); + let mut excluded_files = Vec::new(); + + for (path, relative_path) in files_to_zip { + let is_excluded = glob_set.is_match(&path); + + if (path.is_file() || path.is_dir()) && !is_excluded { if path.is_file() { zip.start_file(relative_path.to_string_lossy(), options)?; - let mut file = File::open(path)?; + let mut file = File::open(&path)?; io::copy(&mut file, &mut zip)?; + added_files.push(path); } else if path.is_dir() { zip.add_directory(relative_path.to_string_lossy(), options)?; } + } else if is_excluded && path.is_file() && target.is_some() { + excluded_files.push(relative_path); } } - zip.finish()?; - Ok(()) -} - -pub fn get_untracked_and_modified_files(repo_path: &str) -> Result, git2::Error> { - // Try to find the repository by walking up the directory tree - let repo = match Repository::discover(repo_path) { - Ok(repo) => repo, - Err(e) => return Err(e), - }; - - let mut changed_files = Vec::new(); - - let statuses = repo.statuses(None)?; - - let mut glob_builder = GlobSetBuilder::new(); - for &pattern in DEFAULT_EXCLUDE_GLOBS { - glob_builder.add(Glob::new(pattern).unwrap()); - } - let glob_set = glob_builder.build().unwrap(); - - for entry in statuses.iter() { - let status = entry.status(); - let file_path = entry.path().unwrap_or("").to_string(); - - let ignore_file = glob_set.is_match(&file_path); - if (status.contains(git2::Status::WT_NEW) && !status.contains(git2::Status::IGNORED) && !ignore_file) - || status.contains(git2::Status::WT_MODIFIED) - || status.contains(git2::Status::WT_DELETED) { - changed_files.push(file_path); + // Print warnings for excluded files + if !excluded_files.is_empty() { + eprintln!( + "\n{}", + set_text_color( + "⚠️ Not everything in your target is scannable.", + TerminalColor::Yellow + ) + ); + eprintln!( + " {}", + set_text_color( + "We skipped files that typically aren't useful for analysis (like vendor/dependency code, test fixtures, style assets, and other non-source files).", + TerminalColor::Yellow + ) + ); + for excluded_file in &excluded_files { + eprintln!( + " {} {}", + set_text_color("•", TerminalColor::Yellow), + excluded_file.display() + ); } + eprintln!(); } - Ok(changed_files) + zip.finish()?; + Ok(added_files) } pub fn create_path_if_not_exists>(path: P) -> io::Result<()> { @@ -168,6 +188,67 @@ pub fn get_current_working_directory() -> Option { .and_then(|path| path.file_name().map(|name| name.to_string_lossy().to_string())) } +/// Determine the project name with fallback logic: +/// 1. Use provided project_name if given +/// 2. Try to get git repository name from remote URL +/// 3. Fall back to current directory name +pub fn determine_project_name(provided_name: Option<&str>) -> String { + if let Some(name) = provided_name { + return sanitize_filename(name); + } + + if let Ok(Some(repo_info)) = get_repo_info("./") { + if let Some(repo_url) = repo_info.repo_url { + if let Some(name) = extract_repo_name_from_url(&repo_url) { + return sanitize_filename(&name); + } + } + } + + let dir_name = get_current_working_directory().unwrap_or_else(|| "unknown".to_string()); + sanitize_filename(&dir_name) +} + +fn sanitize_filename(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { + c + } else { + '_' + } + }) + .collect() +} + +fn extract_repo_name_from_url(url: &str) -> Option { + // Handle various git URL formats: + // - https://github.com/user/repo.git + // - git@github.com:user/repo.git + // - https://github.com/user/repo + // - git@github.com:user/repo + + let url = url.trim(); + + let url = url.strip_suffix(".git").unwrap_or(url); + + if let Some(name) = url.split('/').last() { + let name = name.trim(); + if !name.is_empty() { + return Some(name.to_string()); + } + } + + if let Some(name) = url.split(':').last() { + let name = name.trim(); + if !name.is_empty() { + return Some(name.to_string()); + } + } + + None +} + pub fn get_env_var_if_exists(var_name: &str) -> Option { match env::var(var_name) { Ok(value) if !value.trim().is_empty() => Some(value), diff --git a/src/utils/terminal.rs b/src/utils/terminal.rs index 0f923a8..4c726eb 100644 --- a/src/utils/terminal.rs +++ b/src/utils/terminal.rs @@ -60,6 +60,7 @@ pub fn set_text_color(txt: &str, color: TerminalColor) -> String { TerminalColor::Red => "\x1b[31m", TerminalColor::Green => "\x1b[32m", TerminalColor::Blue => "\x1b[34m", + TerminalColor::Yellow => "\x1b[33m", TerminalColor::Reset => "\x1b[0m", }; return format!("{}{}{}", color_code, txt, "\x1b[0m"); @@ -225,4 +226,5 @@ pub enum TerminalColor { Red, Green, Blue, + Yellow, } \ No newline at end of file