use std::collections::BTreeMap;
use std::io;
use std::path::Path;
use std::process::Command;
use std::time::SystemTime;
use walkdir::WalkDir;
use colored::Colorize;
use serde::Serialize;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum EnvError {
#[error("IO error: {0}")]
Io(#[from] io::Error),
}
#[derive(Debug, Serialize)]
pub struct PathEntry {
pub index: usize,
pub path: String,
pub exists: bool,
pub duplicate_of: Option<usize>,
pub tools: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct DotenvFile {
pub path: String,
pub key_count: usize,
pub gitignored: bool,
pub sensitive_keys: Vec<String>,
}
#[derive(Debug, Serialize, Clone)]
pub struct GitConfigEntry {
pub key: String,
pub value: Option<String>,
pub warning: Option<String>,
}
#[derive(Debug, Serialize, Clone)]
pub struct SshKeyInfo {
pub filename: String,
pub key_type: String,
pub bits: Option<u32>,
pub age_days: Option<u64>,
pub warning: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct EnvReport {
pub path_entries: Vec<PathEntry>,
pub path_issues: usize,
pub dev_vars: BTreeMap<String, Option<String>>,
pub proxy_vars: BTreeMap<String, Option<String>>,
pub ci_detected: Option<String>,
pub dotenv_files: Vec<DotenvFile>,
pub git_config: Vec<GitConfigEntry>,
pub ssh_keys: Vec<SshKeyInfo>,
}
const COMMON_TOOLS: &[&str] = &[
"git", "node", "npm", "python", "python3", "cargo", "rustc", "docker", "java", "go", "code",
"vim", "nvim",
];
const DEV_VARS: &[&str] = &[
"CARGO_HOME",
"RUSTUP_HOME",
"GOPATH",
"GOROOT",
"JAVA_HOME",
"NODE_PATH",
"VIRTUAL_ENV",
"CONDA_DEFAULT_ENV",
"EDITOR",
"VISUAL",
];
const PROXY_VARS: &[&str] = &[
"HTTP_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"no_proxy",
"all_proxy",
];
const CI_VARS: &[(&str, &str)] = &[
("GITHUB_ACTIONS", "GitHub Actions"),
("GITLAB_CI", "GitLab CI"),
("JENKINS_URL", "Jenkins"),
("TF_BUILD", "Azure DevOps"),
("CIRCLECI", "CircleCI"),
("TRAVIS", "Travis CI"),
("CI", "Generic CI"),
];
fn path_separator() -> char {
if cfg!(windows) {
';'
} else {
':'
}
}
fn find_tools_in_dir(dir: &Path) -> Vec<String> {
let mut found = Vec::new();
if !dir.is_dir() {
return found;
}
for tool in COMMON_TOOLS {
let candidates = if cfg!(windows) {
vec![
format!("{tool}.exe"),
format!("{tool}.cmd"),
format!("{tool}.bat"),
tool.to_string(),
]
} else {
vec![tool.to_string()]
};
for candidate in &candidates {
if dir.join(candidate).exists() {
found.push(tool.to_string());
break;
}
}
}
found
}
fn analyze() -> EnvReport {
let path_var = std::env::var("PATH").unwrap_or_default();
let path_dirs: Vec<&str> = path_var.split(path_separator()).collect();
let mut path_entries = Vec::new();
let mut seen: BTreeMap<String, usize> = BTreeMap::new();
let mut issues = 0;
for (i, dir) in path_dirs.iter().enumerate() {
let dir_str = dir.to_string();
let idx = i + 1;
let exists = Path::new(dir).is_dir();
#[cfg(windows)]
let canonical = dir.to_lowercase();
#[cfg(not(windows))]
let canonical = dir_str.clone();
let duplicate_of = seen.get(&canonical).copied();
if duplicate_of.is_none() {
seen.insert(canonical, idx);
}
let tools = if exists {
find_tools_in_dir(Path::new(dir))
} else {
Vec::new()
};
if !exists || duplicate_of.is_some() {
issues += 1;
}
path_entries.push(PathEntry {
index: idx,
path: dir_str,
exists,
duplicate_of,
tools,
});
}
let mut dev_vars = BTreeMap::new();
for &var in DEV_VARS {
dev_vars.insert(var.to_string(), std::env::var(var).ok());
}
if cfg!(windows) {
dev_vars.insert("COMSPEC".to_string(), std::env::var("COMSPEC").ok());
} else {
dev_vars.insert("SHELL".to_string(), std::env::var("SHELL").ok());
}
let mut proxy_vars = BTreeMap::new();
for &var in PROXY_VARS {
let val = std::env::var(var).ok();
if val.is_some() {
proxy_vars.insert(var.to_string(), val);
}
}
let ci_detected = CI_VARS
.iter()
.find(|(var, _)| std::env::var(var).is_ok())
.map(|(_, name)| name.to_string());
let dotenv_files = scan_dotenv_files();
let git_config = collect_git_config();
let ssh_keys = audit_ssh_keys();
EnvReport {
path_entries,
path_issues: issues,
dev_vars,
proxy_vars,
ci_detected,
dotenv_files,
git_config,
ssh_keys,
}
}
const SENSITIVE_PATTERNS: &[&str] = &[
"PASSWORD", "SECRET", "TOKEN", "API_KEY", "APIKEY",
"PRIVATE", "CREDENTIAL", "AUTH",
];
fn scan_dotenv_files() -> Vec<DotenvFile> {
let cwd = match std::env::current_dir() {
Ok(d) => d,
Err(_) => return Vec::new(),
};
let dotenv_names: &[&str] = &[
".env", ".env.local", ".env.development", ".env.production",
".env.staging", ".env.test", ".env.example",
];
let mut files = Vec::new();
let walker = WalkDir::new(&cwd)
.max_depth(3)
.follow_links(false)
.into_iter()
.filter_entry(|e| {
let name = e.file_name().to_string_lossy();
if e.file_type().is_dir() {
return !matches!(name.as_ref(), "node_modules" | "target" | ".git" | "__pycache__" | ".venv" | "venv");
}
true
});
for entry in walker.flatten() {
if !entry.file_type().is_file() {
continue;
}
let name = entry.file_name().to_string_lossy();
if !dotenv_names.iter().any(|&n| name == n) {
continue;
}
let path = entry.path();
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
let mut key_count = 0usize;
let mut sensitive_keys = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if let Some((key, _)) = trimmed.split_once('=') {
let key = key.trim();
key_count += 1;
let upper = key.to_uppercase();
if SENSITIVE_PATTERNS.iter().any(|p| upper.contains(p)) {
sensitive_keys.push(key.to_string());
}
}
}
let gitignored = check_gitignored(path);
files.push(DotenvFile {
path: path.display().to_string(),
key_count,
gitignored,
sensitive_keys,
});
}
files
}
fn check_gitignored(dotenv_path: &Path) -> bool {
let candidates = [
dotenv_path.parent().map(|p| p.join(".gitignore")),
dotenv_path.parent().and_then(|p| p.parent()).map(|p| p.join(".gitignore")),
];
for candidate in candidates.iter().flatten() {
if let Ok(content) = std::fs::read_to_string(candidate) {
for line in content.lines() {
let l = line.trim();
if l == ".env" || l == ".env*" || l == ".env.*" || l == "*.env" {
return true;
}
}
}
}
false
}
const GIT_CONFIG_KEYS: &[&str] = &[
"user.name",
"user.email",
"commit.gpgsign",
"core.editor",
"core.autocrlf",
"credential.helper",
"init.defaultBranch",
];
fn collect_git_config() -> Vec<GitConfigEntry> {
GIT_CONFIG_KEYS
.iter()
.map(|&key| {
let value = Command::new("git")
.args(["config", "--global", key])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty());
let warning = match key {
"user.name" if value.is_none() => {
Some("Git user.name not set — commits will have no author name".to_string())
}
"user.email" if value.is_none() => {
Some("Git user.email not set — commits will have no email".to_string())
}
"commit.gpgsign" => match &value {
Some(v) if v == "true" => None,
_ => Some("Commits are not GPG-signed".to_string()),
},
"credential.helper" if value.is_none() => {
Some("No credential helper — may be prompted for passwords".to_string())
}
_ => None,
};
GitConfigEntry {
key: key.to_string(),
value,
warning,
}
})
.collect()
}
fn audit_ssh_keys() -> Vec<SshKeyInfo> {
let ssh_dir = if cfg!(windows) {
std::env::var("USERPROFILE")
.map(|p| Path::new(&p).join(".ssh"))
.ok()
} else {
std::env::var("HOME")
.map(|p| Path::new(&p).join(".ssh"))
.ok()
};
let ssh_dir = match ssh_dir {
Some(d) if d.is_dir() => d,
_ => return Vec::new(),
};
let mut keys = Vec::new();
let entries = match std::fs::read_dir(&ssh_dir) {
Ok(e) => e,
Err(_) => return Vec::new(),
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if !name.ends_with(".pub") {
continue;
}
if name == "known_hosts.pub" || name == "authorized_keys.pub" {
continue;
}
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
let parts: Vec<&str> = content.split_whitespace().collect();
if parts.is_empty() {
continue;
}
let key_type_raw = parts[0];
let (key_type, bits) = match key_type_raw {
"ssh-rsa" => {
let key_len = parts.get(1).map(|k| k.len()).unwrap_or(0);
let estimated = match key_len {
0..=200 => 1024u32,
201..=400 => 2048,
401..=600 => 3072,
_ => 4096,
};
("RSA".to_string(), Some(estimated))
}
"ssh-ed25519" => ("ED25519".to_string(), Some(256)),
"ecdsa-sha2-nistp256" => ("ECDSA".to_string(), Some(256)),
"ecdsa-sha2-nistp384" => ("ECDSA".to_string(), Some(384)),
"ecdsa-sha2-nistp521" => ("ECDSA".to_string(), Some(521)),
"ssh-dss" => ("DSA".to_string(), Some(1024)),
other => (other.to_string(), None),
};
let age_days = entry
.metadata()
.ok()
.and_then(|m| m.modified().ok())
.and_then(|modified| {
SystemTime::now()
.duration_since(modified)
.ok()
.map(|d| d.as_secs() / 86400)
});
let mut warnings = Vec::new();
if key_type == "DSA" {
warnings.push("DSA keys are deprecated and insecure".to_string());
}
if key_type == "RSA" {
if let Some(b) = bits {
if b < 3072 {
warnings.push(format!("RSA-{b} is below recommended minimum (3072)"));
}
}
}
if let Some(days) = age_days {
if days > 730 {
warnings.push(format!("Key is {} days old (>2 years) — consider rotating", days));
}
}
let warning = if warnings.is_empty() {
None
} else {
Some(warnings.join("; "))
};
keys.push(SshKeyInfo {
filename: name,
key_type,
bits,
age_days,
warning,
});
}
keys.sort_by(|a, b| a.filename.cmp(&b.filename));
keys
}
pub fn collect_env() -> EnvReport {
analyze()
}
pub fn run(filter: Option<&str>, json: bool) -> Result<(), EnvError> {
let report = analyze();
if json {
let json_str = serde_json::to_string_pretty(&report).map_err(io::Error::other)?;
println!("{json_str}");
return Ok(());
}
let show_all = filter.is_none();
let filter_lower = filter.map(|f| f.to_lowercase());
println!();
println!(
" {} {} {} {} {}",
"devpulse".bold(),
"──".dimmed(),
"Env".bold(),
"──".dimmed(),
"Developer Environment".dimmed()
);
println!();
if show_all || filter_lower.as_deref() == Some("path") {
println!(
" {} ({} entries, {} issue(s)):",
"PATH".bold(),
report.path_entries.len(),
report.path_issues
);
for entry in &report.path_entries {
let idx = format!("{:>3}", entry.index);
if !entry.exists {
println!(
" {} {} {}",
idx.dimmed(),
entry.path,
"[NOT FOUND]".red().bold()
);
} else if let Some(dup) = entry.duplicate_of {
println!(
" {} {} {}",
idx.dimmed(),
entry.path,
format!("[DUPLICATE of #{dup}]").yellow().bold()
);
} else if !entry.tools.is_empty() {
println!(
" {} {} {}",
idx.dimmed(),
entry.path,
entry.tools.join(", ").dimmed()
);
} else {
println!(" {} {}", idx.dimmed(), entry.path);
}
}
println!();
}
if show_all || filter_lower.as_deref() == Some("dev") || filter_lower.as_deref() == Some("vars")
{
println!(" {}:", "Dev Tools".bold());
for (key, val) in &report.dev_vars {
match val {
Some(v) => println!(" {:<20} {}", key, v),
None => println!(" {:<20} {}", key, "— (not set)".dimmed()),
}
}
println!();
}
if show_all || filter_lower.as_deref() == Some("proxy") {
print!(" {}: ", "Proxy".bold());
if report.proxy_vars.is_empty() {
println!("{}", "none configured".dimmed());
} else {
println!();
for (key, val) in &report.proxy_vars {
if let Some(v) = val {
println!(" {:<20} {}", key, v);
}
}
}
println!();
}
if show_all || filter_lower.as_deref() == Some("ci") {
print!(" {}: ", "CI".bold());
match &report.ci_detected {
Some(name) => println!("{}", name.green().bold()),
None => println!("{}", "not detected".dimmed()),
}
println!();
}
if show_all || filter_lower.as_deref() == Some("dotenv") || filter_lower.as_deref() == Some(".env") {
println!(" {}:", ".env Files".bold());
if report.dotenv_files.is_empty() {
println!(" {}", "none found".dimmed());
} else {
for df in &report.dotenv_files {
let status = if !df.gitignored && !df.sensitive_keys.is_empty() {
format!("{}", " ⚠ NOT GITIGNORED".yellow().bold())
} else if df.gitignored {
format!("{}", " ✓ gitignored".green())
} else {
String::new()
};
println!(
" {} ({} keys){}",
df.path,
df.key_count,
status,
);
if !df.sensitive_keys.is_empty() {
println!(
" {} {}",
"sensitive:".yellow(),
df.sensitive_keys.join(", ").yellow()
);
}
}
}
println!();
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_path_separator() {
let sep = path_separator();
if cfg!(windows) {
assert_eq!(sep, ';');
} else {
assert_eq!(sep, ':');
}
}
#[test]
fn test_analyze_returns_report() {
let report = analyze();
assert!(!report.path_entries.is_empty());
}
#[test]
fn test_env_report_serialization() {
let report = EnvReport {
path_entries: vec![PathEntry {
index: 1,
path: "/usr/bin".to_string(),
exists: true,
duplicate_of: None,
tools: vec!["git".to_string()],
}],
path_issues: 0,
dev_vars: BTreeMap::new(),
proxy_vars: BTreeMap::new(),
ci_detected: None,
dotenv_files: Vec::new(),
git_config: Vec::new(),
ssh_keys: Vec::new(),
};
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("path_entries"));
assert!(json.contains("/usr/bin"));
}
#[test]
fn test_ci_vars_constant() {
assert!(CI_VARS.iter().any(|(var, _)| *var == "GITHUB_ACTIONS"));
assert!(CI_VARS.iter().any(|(var, _)| *var == "CI"));
}
}