use crate::cli;
use crate::commands::auto_compact_if_needed;
use crate::commands_project::{detect_project_type, ProjectType};
use crate::format::*;
use crate::prompt::*;
use yoagent::agent::Agent;
use yoagent::*;
pub fn handle_update() -> Result<(), String> {
if is_cargo_dev_build() {
println!(
"{}You're running a development build. Use `cargo install yoyo-agent` to update, \
or build from source with `cargo build --release`.{}",
YELLOW, RESET
);
return Ok(());
}
let latest_release = match fetch_latest_release() {
Ok(release) => release,
Err(e) => {
let install_cmd = if std::env::consts::OS == "windows" {
"irm https://raw.githubusercontent.com/yologdev/yoyo-evolve/main/install.ps1 | iex"
} else {
"curl -fsSL https://raw.githubusercontent.com/yologdev/yoyo-evolve/main/install.sh | bash"
};
return Err(format!(
"Failed to check for updates: {}. Try manual install:\n {}",
e, install_cmd
));
}
};
let current_version = cli::VERSION;
let tag_name = latest_release
.get("tag_name")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let tag_version = tag_name.strip_prefix('v').unwrap_or(tag_name);
if !cli::version_is_newer(current_version, tag_version) {
println!(
"Already on the latest version (v{}). No update needed.",
current_version
);
return Ok(());
}
let latest_version = tag_name;
println!(
"Update available: v{} → {}",
current_version, latest_version
);
let (os, arch) = (std::env::consts::OS, std::env::consts::ARCH);
let asset_name = match platform_asset_name(os, arch) {
Some(name) => name,
None => {
return Err(format!("Unsupported platform: {} {}", os, arch));
}
};
let empty_assets = Vec::new();
let assets = latest_release
.get("assets")
.and_then(|v| v.as_array())
.unwrap_or(&empty_assets);
let download_url = match find_asset_url(assets, asset_name) {
Some(url) => url,
None => {
let install_cmd = if os == "windows" {
"irm https://raw.githubusercontent.com/yologdev/yoyo-evolve/main/install.ps1 | iex"
} else {
"curl -fsSL https://raw.githubusercontent.com/yologdev/yoyo-evolve/main/install.sh | bash"
};
return Err(format!(
"No pre-built binary available for your platform ({} {}). Please install manually:\n {}",
os, arch, install_cmd
));
}
};
print!("This will download and replace the current binary.\nContinue? [y/N] ");
std::io::Write::flush(&mut std::io::stdout()).unwrap();
let mut input = String::new();
std::io::stdin()
.read_line(&mut input)
.map_err(|e| format!("Failed to read input: {}", e))?;
let input = input.trim().to_lowercase();
if !matches!(input.as_str(), "y" | "yes") {
println!("Update cancelled.");
return Ok(());
}
let temp_path = format!(
"/tmp/yoyo-update-{}.{}",
latest_version,
if asset_name.ends_with(".zip") {
"zip"
} else {
"tar.gz"
}
);
println!("Downloading {}...", asset_name);
match download_file(&download_url, &temp_path) {
Ok(_) => (),
Err(e) => {
let install_cmd = if os == "windows" {
"irm https://raw.githubusercontent.com/yologdev/yoyo-evolve/main/install.ps1 | iex"
} else {
"curl -fsSL https://raw.githubusercontent.com/yologdev/yoyo-evolve/main/install.sh | bash"
};
return Err(format!(
"Download failed: {}. Please try manual install:\n {}",
e, install_cmd
));
}
}
let extract_dir = "/tmp/yoyo-update-dir";
match extract_archive(&temp_path, extract_dir) {
Ok(binary_path) => {
let current_exe = std::env::current_exe()
.map_err(|e| format!("Failed to get current executable path: {}", e))?;
let backup_path = format!("{}.bak", current_exe.display());
std::fs::copy(¤t_exe, &backup_path)
.map_err(|e| format!("Failed to create backup: {}", e))?;
std::fs::copy(&binary_path, ¤t_exe)
.map_err(|e| format!("Failed to replace binary: {}", e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(¤t_exe)
.map_err(|e| format!("Failed to get file metadata: {}", e))?
.permissions();
perms.set_mode(0o755); std::fs::set_permissions(¤t_exe, perms)
.map_err(|e| format!("Failed to set permissions: {}", e))?;
}
let _ = std::fs::remove_file(&temp_path);
let _ = std::fs::remove_dir_all(extract_dir);
println!(
"✓ Updated to v{}! Please restart yoyo to use the new version.",
latest_version
);
Ok(())
}
Err(e) => {
let current_exe = match std::env::current_exe() {
Ok(exe) => exe,
Err(_) => {
return Err(format!(
"Failed to extract and failed to get current executable: {}",
e
))
}
};
let backup_path = format!("{}.bak", current_exe.display());
if std::path::Path::new(&backup_path).exists() {
let _ = std::fs::copy(&backup_path, ¤t_exe);
let _ = std::fs::remove_file(&backup_path);
}
Err(format!("Failed to extract archive: {}", e))
}
}
}
fn platform_asset_name(os: &str, arch: &str) -> Option<&'static str> {
match (os, arch) {
("linux", "x86_64") => Some("yoyo-x86_64-unknown-linux-gnu.tar.gz"),
("macos", "x86_64") => Some("yoyo-x86_64-apple-darwin.tar.gz"),
("macos", "aarch64") => Some("yoyo-aarch64-apple-darwin.tar.gz"),
("windows", "x86_64") => Some("yoyo-x86_64-pc-windows-msvc.zip"),
_ => None,
}
}
fn is_cargo_dev_build() -> bool {
std::env::current_exe()
.ok()
.and_then(|p| p.to_str().map(|s| s.to_string()))
.map(|p| {
p.contains("/target/debug/")
|| p.contains("/target/release/")
|| p.contains("\\target\\debug\\")
|| p.contains("\\target\\release\\")
})
.unwrap_or(false)
}
fn fetch_latest_release() -> Result<serde_json::Value, String> {
let output = std::process::Command::new("curl")
.args([
"-sf",
"--connect-timeout",
"10",
"--max-time",
"30",
"https://api.github.com/repos/yologdev/yoyo-evolve/releases/latest",
])
.output()
.map_err(|e| format!("Failed to run curl: {}", e))?;
if !output.status.success() {
return Err(format!(
"GitHub API request failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
let response = String::from_utf8_lossy(&output.stdout);
serde_json::from_str(&response).map_err(|e| format!("Failed to parse JSON response: {}", e))
}
fn find_asset_url(assets: &[serde_json::Value], asset_name: &str) -> Option<String> {
assets
.iter()
.find(|asset| {
asset
.get("name")
.and_then(|name| name.as_str())
.map(|name| name == asset_name)
.unwrap_or(false)
})
.and_then(|asset| asset.get("browser_download_url"))
.and_then(|url| url.as_str())
.map(|url| url.to_string())
}
fn download_file(url: &str, path: &str) -> Result<(), String> {
std::process::Command::new("curl")
.args(["-fSL", "-o", path, url])
.output()
.map_err(|e| format!("Failed to run curl: {}", e))?
.status
.success()
.then_some(())
.ok_or_else(|| "Download failed".to_string())
}
fn extract_archive(archive_path: &str, extract_dir: &str) -> Result<String, String> {
std::fs::create_dir_all(extract_dir)
.map_err(|e| format!("Failed to create extract directory: {}", e))?;
if archive_path.ends_with(".tar.gz") {
std::process::Command::new("tar")
.args(["xzf", archive_path, "-C", extract_dir])
.output()
.map_err(|e| format!("Failed to extract tar.gz: {}", e))?
.status
.success()
.then_some(())
.ok_or_else(|| "Failed to extract tar.gz".to_string())?;
} else if archive_path.ends_with(".zip") {
std::process::Command::new("unzip")
.args([archive_path, "-d", extract_dir])
.output()
.map_err(|e| format!("Failed to extract zip: {}", e))?
.status
.success()
.then_some(())
.ok_or_else(|| "Failed to extract zip".to_string())?;
} else {
return Err("Unsupported archive format".to_string());
}
let entries = std::fs::read_dir(extract_dir)
.map_err(|e| format!("Failed to read extract directory: {}", e))?;
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
let path = entry.path();
if path.is_file() {
if let Some(filename) = path.file_name().and_then(|name| name.to_str()) {
if filename == "yoyo" {
return Ok(path.to_string_lossy().to_string());
}
}
}
}
let entries = std::fs::read_dir(extract_dir)
.map_err(|e| format!("Failed to read extract directory: {}", e))?;
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
let path = entry.path();
if path.is_dir() {
let binary_path = path.join("yoyo");
if binary_path.exists() {
return Ok(binary_path.to_string_lossy().to_string());
}
}
}
Err("Could not find yoyo binary in extracted archive".to_string())
}
#[derive(Debug, Clone, PartialEq)]
pub enum DoctorStatus {
Pass,
Fail,
Warn,
}
#[derive(Debug, Clone)]
pub struct DoctorCheck {
pub name: String,
pub status: DoctorStatus,
pub detail: String,
}
pub fn run_doctor_checks(provider: &str, model: &str) -> Vec<DoctorCheck> {
let mut checks = Vec::new();
checks.push(DoctorCheck {
name: "Version".to_string(),
status: DoctorStatus::Pass,
detail: cli::VERSION.to_string(),
});
match std::process::Command::new("git").arg("--version").output() {
Ok(output) if output.status.success() => {
let ver = String::from_utf8_lossy(&output.stdout)
.trim()
.replace("git version ", "")
.to_string();
checks.push(DoctorCheck {
name: "Git".to_string(),
status: DoctorStatus::Pass,
detail: format!("installed ({ver})"),
});
}
_ => {
checks.push(DoctorCheck {
name: "Git".to_string(),
status: DoctorStatus::Fail,
detail: "not found".to_string(),
});
}
}
match std::process::Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.output()
{
Ok(output) if output.status.success() => {
let branch = std::process::Command::new("git")
.args(["branch", "--show-current"])
.output()
.ok()
.and_then(|o| {
if o.status.success() {
let b = String::from_utf8_lossy(&o.stdout).trim().to_string();
if b.is_empty() {
None
} else {
Some(b)
}
} else {
None
}
})
.unwrap_or_else(|| "detached".to_string());
checks.push(DoctorCheck {
name: "Git repo".to_string(),
status: DoctorStatus::Pass,
detail: format!("yes (branch: {branch})"),
});
}
_ => {
checks.push(DoctorCheck {
name: "Git repo".to_string(),
status: DoctorStatus::Warn,
detail: "not inside a git repository".to_string(),
});
}
}
checks.push(DoctorCheck {
name: "Provider".to_string(),
status: DoctorStatus::Pass,
detail: provider.to_string(),
});
let env_var = cli::provider_api_key_env(provider);
match env_var {
Some(var_name) => {
if std::env::var(var_name).is_ok() {
checks.push(DoctorCheck {
name: "API key".to_string(),
status: DoctorStatus::Pass,
detail: format!("set ({var_name})"),
});
} else {
checks.push(DoctorCheck {
name: "API key".to_string(),
status: DoctorStatus::Fail,
detail: format!("{var_name} not set"),
});
}
}
None => {
if provider == "ollama" {
checks.push(DoctorCheck {
name: "API key".to_string(),
status: DoctorStatus::Pass,
detail: "not required (ollama)".to_string(),
});
} else {
checks.push(DoctorCheck {
name: "API key".to_string(),
status: DoctorStatus::Warn,
detail: format!("unknown env var for provider '{provider}'"),
});
}
}
}
checks.push(DoctorCheck {
name: "Model".to_string(),
status: DoctorStatus::Pass,
detail: model.to_string(),
});
let mut config_found = Vec::new();
if std::path::Path::new(".yoyo.toml").exists() {
config_found.push(".yoyo.toml");
}
if let Some(user_path) = cli::user_config_path() {
if user_path.exists() {
config_found.push("~/.config/yoyo/config.toml");
}
}
if config_found.is_empty() {
checks.push(DoctorCheck {
name: "Config file".to_string(),
status: DoctorStatus::Warn,
detail: "none found (.yoyo.toml or ~/.config/yoyo/config.toml)".to_string(),
});
} else {
checks.push(DoctorCheck {
name: "Config file".to_string(),
status: DoctorStatus::Pass,
detail: format!("found: {}", config_found.join(", ")),
});
}
let context_files = cli::list_project_context_files();
if context_files.is_empty() {
checks.push(DoctorCheck {
name: "Project context".to_string(),
status: DoctorStatus::Warn,
detail: "no context file (create YOYO.md or run /init)".to_string(),
});
} else {
let descriptions: Vec<String> = context_files
.iter()
.map(|(name, lines)| format!("{name} ({lines} lines)"))
.collect();
checks.push(DoctorCheck {
name: "Project context".to_string(),
status: DoctorStatus::Pass,
detail: descriptions.join(", "),
});
}
match std::process::Command::new("curl").arg("--version").output() {
Ok(output) if output.status.success() => {
checks.push(DoctorCheck {
name: "Curl".to_string(),
status: DoctorStatus::Pass,
detail: "installed (for /docs and /web)".to_string(),
});
}
_ => {
checks.push(DoctorCheck {
name: "Curl".to_string(),
status: DoctorStatus::Warn,
detail: "not found (/docs and /web won't work)".to_string(),
});
}
}
if std::path::Path::new(".yoyo").is_dir() {
checks.push(DoctorCheck {
name: "Memory dir".to_string(),
status: DoctorStatus::Pass,
detail: ".yoyo/ found".to_string(),
});
} else {
checks.push(DoctorCheck {
name: "Memory dir".to_string(),
status: DoctorStatus::Warn,
detail: ".yoyo/ not found (run /remember to create)".to_string(),
});
}
checks
}
pub fn print_doctor_report(checks: &[DoctorCheck]) {
println!("\n {BOLD}🩺 yoyo doctor{RESET}");
println!(" {DIM}─────────────────────────────{RESET}");
for check in checks {
let (icon, color) = match check.status {
DoctorStatus::Pass => ("✓", &GREEN),
DoctorStatus::Fail => ("✗", &RED),
DoctorStatus::Warn => ("⚠", &YELLOW),
};
println!(
" {color}{icon}{RESET} {BOLD}{}{RESET}: {}",
check.name, check.detail
);
}
let passed = checks
.iter()
.filter(|c| c.status == DoctorStatus::Pass)
.count();
let total = checks.len();
let summary_color = if passed == total { &GREEN } else { &YELLOW };
println!("\n {summary_color}{passed}/{total} checks passed{RESET}\n");
}
pub fn handle_doctor(provider: &str, model: &str) {
let checks = run_doctor_checks(provider, model);
print_doctor_report(&checks);
}
#[allow(clippy::vec_init_then_push, unused_mut)]
pub fn health_checks_for_project(
project_type: &ProjectType,
) -> Vec<(&'static str, Vec<&'static str>)> {
match project_type {
ProjectType::Rust => {
let mut checks = vec![("build", vec!["cargo", "build"])];
#[cfg(not(test))]
checks.push(("test", vec!["cargo", "test"]));
checks.push((
"clippy",
vec!["cargo", "clippy", "--all-targets", "--", "-D", "warnings"],
));
checks.push(("fmt", vec!["cargo", "fmt", "--", "--check"]));
checks
}
ProjectType::Node => {
let mut checks: Vec<(&str, Vec<&str>)> = vec![];
#[cfg(not(test))]
checks.push(("test", vec!["npm", "test"]));
checks.push(("lint", vec!["npx", "eslint", "."]));
checks
}
ProjectType::Python => {
let mut checks: Vec<(&str, Vec<&str>)> = vec![];
#[cfg(not(test))]
checks.push(("test", vec!["python", "-m", "pytest"]));
checks.push(("lint", vec!["python", "-m", "flake8", "."]));
checks.push(("typecheck", vec!["python", "-m", "mypy", "."]));
checks
}
ProjectType::Go => {
let mut checks = vec![("build", vec!["go", "build", "./..."])];
#[cfg(not(test))]
checks.push(("test", vec!["go", "test", "./..."]));
checks.push(("vet", vec!["go", "vet", "./..."]));
checks
}
ProjectType::Make => {
#[cfg(not(test))]
{
vec![("test", vec!["make", "test"])]
}
#[cfg(test)]
{
vec![]
}
}
ProjectType::Unknown => vec![],
}
}
pub fn run_health_check_for_project(
project_type: &ProjectType,
) -> Vec<(&'static str, bool, String)> {
let checks = health_checks_for_project(project_type);
let mut results = Vec::new();
for (name, args) in checks {
let start = std::time::Instant::now();
let output = std::process::Command::new(args[0])
.args(&args[1..])
.output();
let elapsed = format_duration(start.elapsed());
match output {
Ok(o) if o.status.success() => {
results.push((name, true, format!("ok ({elapsed})")));
}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
let first_line = stderr.lines().next().unwrap_or("(unknown error)");
results.push((
name,
false,
format!(
"FAIL ({elapsed}): {}",
truncate_with_ellipsis(first_line, 80)
),
));
}
Err(e) => {
results.push((name, false, format!("ERROR: {e}")));
}
}
}
results
}
pub fn run_health_checks_full_output(
project_type: &ProjectType,
) -> Vec<(&'static str, bool, String)> {
let checks = health_checks_for_project(project_type);
let mut results = Vec::new();
for (name, args) in checks {
let output = std::process::Command::new(args[0])
.args(&args[1..])
.output();
match output {
Ok(o) if o.status.success() => {
results.push((name, true, String::new()));
}
Ok(o) => {
let stdout = String::from_utf8_lossy(&o.stdout);
let stderr = String::from_utf8_lossy(&o.stderr);
let mut full_output = String::new();
if !stdout.is_empty() {
full_output.push_str(&stdout);
}
if !stderr.is_empty() {
if !full_output.is_empty() {
full_output.push('\n');
}
full_output.push_str(&stderr);
}
results.push((name, false, full_output));
}
Err(e) => {
results.push((name, false, format!("ERROR: {e}")));
}
}
}
results
}
pub fn build_fix_prompt(failures: &[(&str, &str)]) -> String {
if failures.is_empty() {
return String::new();
}
let mut prompt = String::from(
"Fix the following build/lint errors in this project. Read the relevant files, understand the errors, and apply fixes:\n\n",
);
for (name, output) in failures {
prompt.push_str(&format!("## {name} errors:\n```\n{output}\n```\n\n"));
}
prompt.push_str(
"After fixing, run the failing checks again to verify. Fix any remaining issues.",
);
prompt
}
pub fn handle_health() {
let project_type = detect_project_type(&std::env::current_dir().unwrap_or_default());
println!("{DIM} Detected project: {project_type}{RESET}");
if project_type == ProjectType::Unknown {
println!(
"{DIM} No recognized project found. Looked for: Cargo.toml, package.json, pyproject.toml, setup.py, go.mod, Makefile{RESET}\n"
);
return;
}
println!("{DIM} Running health checks...{RESET}");
let results = run_health_check_for_project(&project_type);
if results.is_empty() {
println!("{DIM} No checks configured for {project_type}{RESET}\n");
return;
}
let all_passed = results.iter().all(|(_, passed, _)| *passed);
for (name, passed, detail) in &results {
let icon = if *passed {
format!("{GREEN}✓{RESET}")
} else {
format!("{RED}✗{RESET}")
};
println!(" {icon} {name}: {detail}");
}
if all_passed {
println!("\n{GREEN} All checks passed ✓{RESET}\n");
} else {
println!("\n{RED} Some checks failed ✗{RESET}\n");
}
}
pub async fn handle_fix(
agent: &mut Agent,
session_total: &mut Usage,
model: &str,
) -> Option<String> {
let project_type = detect_project_type(&std::env::current_dir().unwrap_or_default());
if project_type == ProjectType::Unknown {
println!(
"{DIM} No recognized project found. Looked for: Cargo.toml, package.json, pyproject.toml, setup.py, go.mod, Makefile{RESET}\n"
);
return None;
}
println!("{DIM} Detected project: {project_type}{RESET}");
println!("{DIM} Running health checks...{RESET}");
let results = run_health_checks_full_output(&project_type);
if results.is_empty() {
println!("{DIM} No checks configured for {project_type}{RESET}\n");
return None;
}
for (name, passed, _) in &results {
let icon = if *passed {
format!("{GREEN}✓{RESET}")
} else {
format!("{RED}✗{RESET}")
};
let status = if *passed { "ok" } else { "FAIL" };
println!(" {icon} {name}: {status}");
}
let failures: Vec<(&str, &str)> = results
.iter()
.filter(|(_, passed, _)| !passed)
.map(|(name, _, output)| (*name, output.as_str()))
.collect();
if failures.is_empty() {
println!("\n{GREEN} All checks passed — nothing to fix ✓{RESET}\n");
return None;
}
let fail_count = failures.len();
println!("\n{YELLOW} Sending {fail_count} failure(s) to AI for fixing...{RESET}\n");
let fix_prompt = build_fix_prompt(&failures);
run_prompt(agent, &fix_prompt, session_total, model).await;
auto_compact_if_needed(agent);
Some(fix_prompt)
}
pub fn test_command_for_project(
project_type: &ProjectType,
) -> Option<(&'static str, Vec<&'static str>)> {
match project_type {
ProjectType::Rust => Some(("cargo test", vec!["cargo", "test"])),
ProjectType::Node => Some(("npm test", vec!["npm", "test"])),
ProjectType::Python => Some(("python -m pytest", vec!["python", "-m", "pytest"])),
ProjectType::Go => Some(("go test ./...", vec!["go", "test", "./..."])),
ProjectType::Make => Some(("make test", vec!["make", "test"])),
ProjectType::Unknown => None,
}
}
pub fn handle_test() -> Option<String> {
let project_type = detect_project_type(&std::env::current_dir().unwrap_or_default());
println!("{DIM} Detected project: {project_type}{RESET}");
if project_type == ProjectType::Unknown {
println!(
"{DIM} No recognized project found. Looked for: Cargo.toml, package.json, pyproject.toml, setup.py, go.mod, Makefile{RESET}\n"
);
return None;
}
let (label, args) = match test_command_for_project(&project_type) {
Some(cmd) => cmd,
None => {
println!("{DIM} No test command configured for {project_type}{RESET}\n");
return None;
}
};
println!("{DIM} Running: {label}...{RESET}");
let start = std::time::Instant::now();
let output = std::process::Command::new(args[0])
.args(&args[1..])
.output();
let elapsed = format_duration(start.elapsed());
match output {
Ok(o) => {
let stdout = String::from_utf8_lossy(&o.stdout);
let stderr = String::from_utf8_lossy(&o.stderr);
if !stdout.is_empty() {
print!("{stdout}");
}
if !stderr.is_empty() {
eprint!("{stderr}");
}
if o.status.success() {
println!("\n{GREEN} ✓ Tests passed ({elapsed}){RESET}\n");
Some(format!("Tests passed ({elapsed}): {label}"))
} else {
let code = o.status.code().unwrap_or(-1);
println!("\n{RED} ✗ Tests failed (exit {code}, {elapsed}){RESET}\n");
let mut summary = format!("Tests FAILED (exit {code}, {elapsed}): {label}");
let error_text = if !stderr.is_empty() {
stderr.to_string()
} else {
stdout.to_string()
};
let lines: Vec<&str> = error_text.lines().collect();
let preview_lines = if lines.len() > 20 {
&lines[lines.len() - 20..]
} else {
&lines
};
summary.push_str("\n\nLast output:\n");
for line in preview_lines {
summary.push_str(line);
summary.push('\n');
}
Some(summary)
}
}
Err(e) => {
eprintln!("{RED} ✗ Failed to run {label}: {e}{RESET}\n");
Some(format!("Failed to run {label}: {e}"))
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LintStrictness {
Default,
Pedantic,
Strict,
}
pub const LINT_SUBCOMMANDS: &[&str] = &["fix", "pedantic", "strict", "unsafe"];
pub fn lint_command_for_project(
project_type: &ProjectType,
strictness: LintStrictness,
) -> Option<(String, Vec<String>)> {
match project_type {
ProjectType::Rust => {
let mut label = String::from("cargo clippy --all-targets -- -D warnings");
let mut args: Vec<String> =
vec!["cargo", "clippy", "--all-targets", "--", "-D", "warnings"]
.into_iter()
.map(String::from)
.collect();
match strictness {
LintStrictness::Default => {}
LintStrictness::Pedantic => {
label.push_str(" -W clippy::pedantic");
args.push("-W".into());
args.push("clippy::pedantic".into());
}
LintStrictness::Strict => {
label.push_str(" -W clippy::pedantic -W clippy::nursery");
args.push("-W".into());
args.push("clippy::pedantic".into());
args.push("-W".into());
args.push("clippy::nursery".into());
}
}
Some((label, args))
}
ProjectType::Node => Some((
"npx eslint .".into(),
vec!["npx".into(), "eslint".into(), ".".into()],
)),
ProjectType::Python => Some((
"ruff check .".into(),
vec!["ruff".into(), "check".into(), ".".into()],
)),
ProjectType::Go => Some((
"golangci-lint run".into(),
vec!["golangci-lint".into(), "run".into()],
)),
ProjectType::Make | ProjectType::Unknown => None,
}
}
pub fn handle_lint(input: &str) -> Option<String> {
let arg = input.strip_prefix("/lint").unwrap_or("").trim();
if arg == "unsafe" {
return handle_lint_unsafe();
}
let strictness = match arg {
"pedantic" => LintStrictness::Pedantic,
"strict" => LintStrictness::Strict,
_ => LintStrictness::Default,
};
let project_type = detect_project_type(&std::env::current_dir().unwrap_or_default());
println!("{DIM} Detected project: {project_type}{RESET}");
if project_type == ProjectType::Unknown {
println!(
"{DIM} No recognized project found. Looked for: Cargo.toml, package.json, pyproject.toml, setup.py, go.mod, Makefile{RESET}\n"
);
return None;
}
let (label, args) = match lint_command_for_project(&project_type, strictness) {
Some(cmd) => cmd,
None => {
println!("{DIM} No lint command configured for {project_type}{RESET}\n");
return None;
}
};
println!("{DIM} Running: {label}...{RESET}");
let start = std::time::Instant::now();
let output = std::process::Command::new(&args[0])
.args(&args[1..])
.output();
let elapsed = format_duration(start.elapsed());
match output {
Ok(o) => {
let stdout = String::from_utf8_lossy(&o.stdout);
let stderr = String::from_utf8_lossy(&o.stderr);
if !stdout.is_empty() {
print!("{stdout}");
}
if !stderr.is_empty() {
eprint!("{stderr}");
}
if o.status.success() {
println!("\n{GREEN} ✓ Lint passed ({elapsed}){RESET}\n");
Some(format!("Lint passed ({elapsed}): {label}"))
} else {
let code = o.status.code().unwrap_or(-1);
println!("\n{RED} ✗ Lint failed (exit {code}, {elapsed}){RESET}\n");
let mut summary = format!("Lint FAILED (exit {code}, {elapsed}): {label}");
let error_text = if !stderr.is_empty() {
stderr.to_string()
} else {
stdout.to_string()
};
let lines: Vec<&str> = error_text.lines().collect();
let preview_lines = if lines.len() > 20 {
&lines[lines.len() - 20..]
} else {
&lines
};
summary.push_str("\n\nLast output:\n");
for line in preview_lines {
summary.push_str(line);
summary.push('\n');
}
Some(summary)
}
}
Err(e) => {
eprintln!("{RED} ✗ Failed to run {label}: {e}{RESET}\n");
Some(format!("Failed to run {label}: {e}"))
}
}
}
pub fn build_lint_fix_prompt(lint_command: &str, lint_output: &str) -> String {
let mut prompt = String::from(
"Fix the following lint errors in this project. Read the relevant files, \
understand the warnings/errors, and apply fixes:\n\n",
);
prompt.push_str(&format!(
"## Lint errors (`{lint_command}`):\n```\n{lint_output}\n```\n\n"
));
prompt
.push_str("After fixing, run the lint command again to verify. Fix any remaining issues.");
prompt
}
pub async fn handle_lint_fix(
agent: &mut Agent,
session_total: &mut Usage,
model: &str,
) -> Option<String> {
let lint_result = handle_lint("/lint");
match lint_result {
Some(ref summary)
if summary.starts_with("Lint FAILED") || summary.starts_with("Failed to run") =>
{
println!("{YELLOW} Sending lint failures to AI for fixing...{RESET}\n");
let project_type = detect_project_type(&std::env::current_dir().unwrap_or_default());
let lint_label = lint_command_for_project(&project_type, LintStrictness::Default)
.map(|(label, _)| label)
.unwrap_or_else(|| "lint".into());
let fix_prompt = build_lint_fix_prompt(&lint_label, summary);
run_prompt(agent, &fix_prompt, session_total, model).await;
auto_compact_if_needed(agent);
Some(fix_prompt)
}
Some(_) => {
println!("{GREEN} No lint errors to fix ✓{RESET}\n");
None
}
None => None,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnsafeOccurrence {
pub file: String,
pub line_number: usize,
pub line_text: String,
pub kind: UnsafeKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UnsafeKind {
Block,
Function,
Impl,
Trait,
}
impl std::fmt::Display for UnsafeKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Block => write!(f, "unsafe block"),
Self::Function => write!(f, "unsafe fn"),
Self::Impl => write!(f, "unsafe impl"),
Self::Trait => write!(f, "unsafe trait"),
}
}
}
pub fn scan_for_unsafe(file_path: &str, content: &str) -> Vec<UnsafeOccurrence> {
let mut results = Vec::new();
for (idx, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("//") || trimmed.starts_with('*') || trimmed.starts_with("/*") {
continue;
}
if let Some(unsafe_pos) = trimmed.find("unsafe") {
let before = &trimmed[..unsafe_pos];
let quote_count = before.chars().filter(|&c| c == '"').count();
if quote_count % 2 == 1 {
continue;
}
let after_unsafe = &trimmed[unsafe_pos + 6..]; let kind = if after_unsafe.trim_start().starts_with("fn ") {
UnsafeKind::Function
} else if after_unsafe.trim_start().starts_with("impl") {
UnsafeKind::Impl
} else if after_unsafe.trim_start().starts_with("trait") {
UnsafeKind::Trait
} else if after_unsafe.trim_start().starts_with('{')
|| after_unsafe.trim_start().is_empty()
|| before.is_empty()
|| before.ends_with(' ')
|| before.ends_with('{')
{
UnsafeKind::Block
} else {
continue; };
results.push(UnsafeOccurrence {
file: file_path.to_string(),
line_number: idx + 1,
line_text: line.to_string(),
kind,
});
}
}
results
}
pub fn has_unsafe_code_attribute(content: &str) -> Option<&'static str> {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("//") {
continue;
}
if trimmed.contains("#![forbid(unsafe_code)]") {
return Some("forbid");
}
if trimmed.contains("#![deny(unsafe_code)]") {
return Some("deny");
}
}
None
}
fn collect_rs_files(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut files = Vec::new();
collect_rs_files_recursive(dir, &mut files);
files.sort();
files
}
fn collect_rs_files_recursive(dir: &std::path::Path, files: &mut Vec<std::path::PathBuf>) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let name = path.file_name().unwrap_or_default().to_string_lossy();
if name == "target" || name == ".git" || name.starts_with('.') {
continue;
}
collect_rs_files_recursive(&path, files);
} else if path.extension().is_some_and(|e| e == "rs") {
files.push(path);
}
}
}
pub fn handle_lint_unsafe() -> Option<String> {
let cwd = std::env::current_dir().unwrap_or_default();
if !cwd.join("Cargo.toml").exists() {
println!("{DIM} /lint unsafe is only available for Rust projects (no Cargo.toml found){RESET}\n");
return None;
}
println!("{DIM} Scanning for unsafe code...{RESET}");
let mut crate_root_attr: Option<&str> = None;
for root_file in &["src/main.rs", "src/lib.rs"] {
let root_path = cwd.join(root_file);
if root_path.exists() {
if let Ok(content) = std::fs::read_to_string(&root_path) {
if let Some(attr) = has_unsafe_code_attribute(&content) {
crate_root_attr = Some(attr);
break;
}
}
}
}
let src_dir = cwd.join("src");
let scan_dir = if src_dir.exists() { &src_dir } else { &cwd };
let rs_files = collect_rs_files(scan_dir);
let mut all_occurrences: Vec<UnsafeOccurrence> = Vec::new();
for file_path in &rs_files {
if let Ok(content) = std::fs::read_to_string(file_path) {
let relative = file_path
.strip_prefix(&cwd)
.unwrap_or(file_path)
.to_string_lossy()
.to_string();
let occurrences = scan_for_unsafe(&relative, &content);
all_occurrences.extend(occurrences);
}
}
let mut summary = String::new();
if all_occurrences.is_empty() {
if let Some(attr) = crate_root_attr {
let msg = format!("✓ No unsafe code found — #![{attr}(unsafe_code)] is active");
println!("\n{GREEN} {msg}{RESET}\n");
summary.push_str(&msg);
} else {
println!("\n{GREEN} ✓ No unsafe code found{RESET}");
println!(
"{YELLOW} 💡 Consider adding #![forbid(unsafe_code)] to your crate root for compile-time enforcement{RESET}\n"
);
summary.push_str(
"No unsafe code found. Suggest adding #![forbid(unsafe_code)] to crate root.",
);
}
} else {
println!(
"\n{YELLOW} ⚠ Found {} unsafe occurrence(s):{RESET}\n",
all_occurrences.len()
);
for occ in &all_occurrences {
println!(
" {RED}{}:{}{RESET} — {} — {}",
occ.file,
occ.line_number,
occ.kind,
occ.line_text.trim()
);
}
summary.push_str(&format!(
"Found {} unsafe occurrence(s):\n",
all_occurrences.len()
));
for occ in &all_occurrences {
summary.push_str(&format!(
" {}:{} — {} — {}\n",
occ.file,
occ.line_number,
occ.kind,
occ.line_text.trim()
));
}
match crate_root_attr {
Some(attr) => {
println!(
"\n{DIM} #![{attr}(unsafe_code)] is set — these unsafe usages require #[allow(unsafe_code)] or will fail to compile{RESET}\n"
);
summary.push_str(&format!("\n#![{attr}(unsafe_code)] is set in crate root."));
}
None => {
println!(
"\n{YELLOW} 💡 No #![deny(unsafe_code)] or #![forbid(unsafe_code)] found in crate root{RESET}"
);
println!(
"{YELLOW} 💡 Consider adding #![forbid(unsafe_code)] to prevent future unsafe additions{RESET}\n"
);
summary.push_str(
"\nNo unsafe_code attribute found. Suggest adding #![forbid(unsafe_code)] to crate root."
);
}
}
}
Some(summary)
}
pub fn detect_test_command() -> Option<String> {
let dir = std::env::current_dir().unwrap_or_default();
let project_type = detect_project_type(&dir);
test_command_for_project(&project_type).map(|(label, _args)| label.to_string())
}
pub const WATCH_SUBCOMMANDS: &[&str] = &["off", "status"];
pub fn handle_watch(input: &str) {
let arg = input.strip_prefix("/watch").unwrap_or("").trim();
match arg {
"" => {
match detect_test_command() {
Some(cmd) => {
crate::prompt::set_watch_command(&cmd);
println!(
"{GREEN} 👀 Watch mode ON — will run `{cmd}` after agent edits{RESET}\n"
);
}
None => {
println!("{DIM} No test command detected. Specify one:{RESET}");
println!("{DIM} /watch cargo test{RESET}");
println!("{DIM} /watch npm test{RESET}\n");
}
}
}
"off" => {
crate::prompt::clear_watch_command();
println!("{DIM} 👀 Watch mode OFF{RESET}\n");
}
"status" => match crate::prompt::get_watch_command() {
Some(cmd) => {
println!("{DIM} 👀 Watch mode: ON{RESET}");
println!("{DIM} Command: `{cmd}`{RESET}\n");
}
None => {
println!("{DIM} 👀 Watch mode: OFF{RESET}\n");
}
},
custom_cmd => {
crate::prompt::set_watch_command(custom_cmd);
println!(
"{GREEN} 👀 Watch mode ON — will run `{custom_cmd}` after agent edits{RESET}\n"
);
}
}
}
pub fn build_project_tree(max_depth: usize) -> String {
let files = match crate::git::run_git(&["ls-files"]) {
Ok(text) => {
let mut files: Vec<String> = text
.lines()
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect();
files.sort();
files
}
Err(_) => return "(not a git repository — /tree requires git)".to_string(),
};
if files.is_empty() {
return "(no tracked files)".to_string();
}
format_tree_from_paths(&files, max_depth)
}
pub fn format_tree_from_paths(paths: &[String], max_depth: usize) -> String {
use std::collections::BTreeSet;
let mut output = String::new();
let mut printed_dirs: BTreeSet<String> = BTreeSet::new();
for path in paths {
let parts: Vec<&str> = path.split('/').collect();
let depth = parts.len() - 1;
for level in 0..parts.len().saturating_sub(1).min(max_depth) {
let dir_path: String = parts[..=level].join("/");
let dir_key = format!("{}/", dir_path);
if printed_dirs.insert(dir_key) {
let indent = " ".repeat(level);
let dir_name = parts[level];
output.push_str(&format!("{indent}{dir_name}/\n"));
}
}
if depth <= max_depth {
let indent = " ".repeat(depth.min(max_depth));
let file_name = parts.last().unwrap_or(&"");
output.push_str(&format!("{indent}{file_name}\n"));
}
}
if output.ends_with('\n') {
output.truncate(output.len() - 1);
}
output
}
pub fn handle_tree(input: &str) {
let arg = input.strip_prefix("/tree").unwrap_or("").trim();
let max_depth = if arg.is_empty() {
3
} else {
match arg.parse::<usize>() {
Ok(d) => d,
Err(_) => {
println!("{DIM} usage: /tree [depth] (default depth: 3){RESET}\n");
return;
}
}
};
let tree = build_project_tree(max_depth);
println!("{DIM}{tree}{RESET}\n");
}
pub fn run_shell_command(cmd: &str) {
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
let start = std::time::Instant::now();
let child = Command::new("sh")
.args(["-c", cmd])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn();
let mut child = match child {
Ok(c) => c,
Err(e) => {
eprintln!("{RED} error running command: {e}{RESET}\n");
return;
}
};
let stderr_pipe = child.stderr.take().expect("stderr was piped");
let stderr_handle = std::thread::spawn(move || {
let reader = BufReader::new(stderr_pipe);
for line in reader.lines() {
match line {
Ok(l) => eprintln!("{RED}{l}{RESET}"),
Err(_) => break,
}
}
});
if let Some(stdout_pipe) = child.stdout.take() {
let reader = BufReader::new(stdout_pipe);
for line in reader.lines() {
match line {
Ok(l) => println!("{l}"),
Err(_) => break,
}
}
}
let _ = stderr_handle.join();
let elapsed = format_duration(start.elapsed());
match child.wait() {
Ok(status) => {
let code = status.code().unwrap_or(-1);
if code == 0 {
println!("{DIM} ✓ exit {code} ({elapsed}){RESET}\n");
} else {
println!("{RED} ✗ exit {code} ({elapsed}){RESET}\n");
}
}
Err(e) => {
eprintln!("{RED} error waiting for command: {e}{RESET}\n");
}
}
}
pub fn handle_run(input: &str) {
let cmd = if input.starts_with("/run ") {
input.trim_start_matches("/run ").trim()
} else if input.starts_with('!') && input.len() > 1 {
input[1..].trim()
} else {
""
};
if cmd.is_empty() {
println!("{DIM} usage: /run <command> or !<command>{RESET}\n");
} else {
run_shell_command(cmd);
}
}
pub fn handle_run_usage() {
println!("{DIM} usage: /run <command> or !<command>");
println!(" Runs a shell command directly (no AI, no tokens).{RESET}\n");
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::{is_unknown_command, KNOWN_COMMANDS};
#[test]
fn test_command_rust() {
let cmd = test_command_for_project(&ProjectType::Rust);
assert!(cmd.is_some());
let (label, _) = cmd.unwrap();
assert_eq!(label, "cargo test");
}
#[test]
fn test_command_unknown() {
assert!(test_command_for_project(&ProjectType::Unknown).is_none());
}
#[test]
fn lint_command_rust() {
let cmd = lint_command_for_project(&ProjectType::Rust, LintStrictness::Default);
assert!(cmd.is_some());
assert!(cmd.unwrap().0.contains("clippy"));
}
#[test]
fn lint_command_make_none() {
assert!(lint_command_for_project(&ProjectType::Make, LintStrictness::Default).is_none());
}
#[test]
fn lint_command_unknown_none() {
assert!(lint_command_for_project(&ProjectType::Unknown, LintStrictness::Default).is_none());
}
#[test]
fn health_checks_rust_has_build() {
let checks = health_checks_for_project(&ProjectType::Rust);
assert!(checks.iter().any(|(name, _)| *name == "build"));
}
#[test]
fn health_checks_unknown_empty() {
let checks = health_checks_for_project(&ProjectType::Unknown);
assert!(checks.is_empty());
}
#[test]
fn build_fix_prompt_empty() {
let prompt = build_fix_prompt(&[]);
assert!(prompt.is_empty());
}
#[test]
fn build_fix_prompt_with_failures() {
let failures = vec![("build", "error[E0308]: mismatched types")];
let prompt = build_fix_prompt(&failures);
assert!(prompt.contains("build errors"));
assert!(prompt.contains("E0308"));
assert!(prompt.contains("Fix"));
}
#[test]
fn build_fix_prompt_multiple_failures() {
let failures = vec![
("build", "build error output"),
("clippy", "clippy warning output"),
];
let prompt = build_fix_prompt(&failures);
assert!(prompt.contains("## build errors"));
assert!(prompt.contains("## clippy errors"));
}
#[test]
fn lint_fix_prompt_contains_command_and_output() {
let prompt = build_lint_fix_prompt(
"cargo clippy --all-targets -- -D warnings",
"warning: unused variable `x`\n --> src/main.rs:5:9",
);
assert!(prompt.contains("cargo clippy"));
assert!(prompt.contains("unused variable"));
assert!(prompt.contains("src/main.rs:5:9"));
}
#[test]
fn lint_fix_prompt_asks_to_fix() {
let prompt = build_lint_fix_prompt("ruff check .", "E501 line too long");
assert!(prompt.contains("Fix the following lint errors"));
assert!(prompt.contains("ruff check ."));
assert!(prompt.contains("E501 line too long"));
assert!(prompt.contains("run the lint command again to verify"));
}
#[test]
fn lint_fix_prompt_includes_structured_output() {
let lint_output = "Lint FAILED (exit 1, 2.3s): cargo clippy\n\nLast output:\nwarning: field `foo` is never read";
let prompt =
build_lint_fix_prompt("cargo clippy --all-targets -- -D warnings", lint_output);
assert!(prompt.contains("## Lint errors"));
assert!(prompt.contains("field `foo` is never read"));
}
#[test]
fn update_platform_linux_x86_64() {
let name = platform_asset_name("linux", "x86_64");
assert_eq!(name, Some("yoyo-x86_64-unknown-linux-gnu.tar.gz"));
}
#[test]
fn update_platform_macos_intel() {
let name = platform_asset_name("macos", "x86_64");
assert_eq!(name, Some("yoyo-x86_64-apple-darwin.tar.gz"));
}
#[test]
fn update_platform_macos_arm() {
let name = platform_asset_name("macos", "aarch64");
assert_eq!(name, Some("yoyo-aarch64-apple-darwin.tar.gz"));
}
#[test]
fn update_platform_windows() {
let name = platform_asset_name("windows", "x86_64");
assert_eq!(name, Some("yoyo-x86_64-pc-windows-msvc.zip"));
}
#[test]
fn update_platform_unsupported() {
assert!(platform_asset_name("freebsd", "x86_64").is_none());
assert!(platform_asset_name("linux", "arm").is_none());
assert!(platform_asset_name("windows", "aarch64").is_none());
}
#[test]
fn update_find_asset_url_found() {
let assets = vec![
serde_json::json!({
"name": "yoyo-x86_64-unknown-linux-gnu.tar.gz",
"browser_download_url": "https://example.com/download/linux.tar.gz"
}),
serde_json::json!({
"name": "yoyo-aarch64-apple-darwin.tar.gz",
"browser_download_url": "https://example.com/download/macos-arm.tar.gz"
}),
];
let url = find_asset_url(&assets, "yoyo-x86_64-unknown-linux-gnu.tar.gz");
assert_eq!(
url,
Some("https://example.com/download/linux.tar.gz".to_string())
);
}
#[test]
fn update_find_asset_url_not_found() {
let assets = vec![serde_json::json!({
"name": "yoyo-x86_64-unknown-linux-gnu.tar.gz",
"browser_download_url": "https://example.com/download/linux.tar.gz"
})];
let url = find_asset_url(&assets, "yoyo-x86_64-pc-windows-msvc.zip");
assert!(url.is_none());
}
#[test]
fn update_find_asset_url_empty() {
let assets: Vec<serde_json::Value> = vec![];
let url = find_asset_url(&assets, "yoyo-x86_64-unknown-linux-gnu.tar.gz");
assert!(url.is_none());
}
#[test]
fn update_version_comparison() {
assert!(cli::version_is_newer("0.1.5", "0.2.0"));
assert!(!cli::version_is_newer("0.2.0", "0.2.0"));
assert!(!cli::version_is_newer("0.3.0", "0.2.0"));
}
#[test]
fn update_is_cargo_dev_build_runs() {
let result = is_cargo_dev_build();
assert!(
result,
"tests run from target/debug, should detect as dev build"
);
}
#[test]
fn format_tree_basic() {
let paths = vec![
"src/main.rs".to_string(),
"src/lib.rs".to_string(),
"Cargo.toml".to_string(),
];
let tree = format_tree_from_paths(&paths, 3);
assert!(tree.contains("src/"));
assert!(tree.contains("main.rs"));
assert!(tree.contains("lib.rs"));
assert!(tree.contains("Cargo.toml"));
}
#[test]
fn format_tree_depth_limit() {
let paths = vec!["a/b/c/d/e.txt".to_string()];
let tree_shallow = format_tree_from_paths(&paths, 1);
assert!(tree_shallow.contains("a/"));
assert!(!tree_shallow.contains("e.txt"));
}
#[test]
fn format_tree_empty() {
let paths: Vec<String> = vec![];
let tree = format_tree_from_paths(&paths, 3);
assert!(tree.is_empty());
}
#[test]
fn format_tree_root_files() {
let paths = vec!["README.md".to_string()];
let tree = format_tree_from_paths(&paths, 3);
assert!(tree.contains("README.md"));
}
#[test]
fn test_health_check_function() {
let project_type = detect_project_type(&std::env::current_dir().unwrap());
assert_eq!(project_type, ProjectType::Rust);
let results = run_health_check_for_project(&project_type);
assert!(
!results.is_empty(),
"Health check should return at least one result"
);
for (name, passed, _) in &results {
assert!(!name.is_empty(), "Check name should not be empty");
if *name == "build" {
assert!(passed, "cargo build should pass in test environment");
}
}
assert!(
!results.iter().any(|(name, _, _)| *name == "test"),
"cargo test check should be skipped to avoid recursion"
);
}
#[test]
fn test_health_checks_for_rust_project() {
let checks = health_checks_for_project(&ProjectType::Rust);
let names: Vec<&str> = checks.iter().map(|(n, _)| *n).collect();
assert!(names.contains(&"build"), "Rust should have build check");
assert!(names.contains(&"clippy"), "Rust should have clippy check");
assert!(names.contains(&"fmt"), "Rust should have fmt check");
assert!(
!names.contains(&"test"),
"test should be excluded in cfg(test)"
);
}
#[test]
fn test_health_checks_for_node_project() {
let checks = health_checks_for_project(&ProjectType::Node);
let names: Vec<&str> = checks.iter().map(|(n, _)| *n).collect();
assert!(names.contains(&"lint"), "Node should have lint check");
}
#[test]
fn test_health_checks_for_go_project() {
let checks = health_checks_for_project(&ProjectType::Go);
let names: Vec<&str> = checks.iter().map(|(n, _)| *n).collect();
assert!(names.contains(&"build"), "Go should have build check");
assert!(names.contains(&"vet"), "Go should have vet check");
}
#[test]
fn test_health_checks_for_python_project() {
let checks = health_checks_for_project(&ProjectType::Python);
let names: Vec<&str> = checks.iter().map(|(n, _)| *n).collect();
assert!(names.contains(&"lint"), "Python should have lint check");
assert!(names.contains(&"typecheck"), "Python should have typecheck");
}
#[test]
fn test_health_checks_for_unknown_returns_empty() {
let checks = health_checks_for_project(&ProjectType::Unknown);
assert!(checks.is_empty(), "Unknown project should return no checks");
}
#[test]
fn test_run_command_recognized() {
assert!(!is_unknown_command("/run"));
assert!(!is_unknown_command("/run echo hello"));
assert!(!is_unknown_command("/run ls -la"));
}
#[test]
fn test_run_shell_command_basic() {
run_shell_command("echo hello");
}
#[test]
fn test_run_shell_command_failing() {
run_shell_command("false");
}
#[test]
fn test_run_shell_command_streams_multiline() {
run_shell_command("echo line1; echo line2; echo line3");
}
#[test]
fn test_run_shell_command_mixed_stdout_stderr() {
run_shell_command("echo out; echo err >&2; echo out2");
}
#[test]
fn test_run_shell_command_large_output() {
run_shell_command("seq 1 100");
}
#[test]
fn test_bang_shortcut_matching() {
let bang_matches = |s: &str| s.starts_with('!') && s.len() > 1;
assert!(bang_matches("!ls"));
assert!(bang_matches("!echo hello"));
assert!(bang_matches("! ls")); assert!(!bang_matches("!")); }
#[test]
fn test_run_command_matching() {
let run_matches = |s: &str| s == "/run" || s.starts_with("/run ");
assert!(run_matches("/run"));
assert!(run_matches("/run echo hello"));
assert!(!run_matches("/running"));
assert!(!run_matches("/runaway"));
}
#[test]
fn test_format_tree_from_paths_basic() {
let paths = vec![
"Cargo.toml".to_string(),
"README.md".to_string(),
"src/cli.rs".to_string(),
"src/format.rs".to_string(),
"src/main.rs".to_string(),
];
let tree = format_tree_from_paths(&paths, 3);
assert!(tree.contains("Cargo.toml"));
assert!(tree.contains("README.md"));
assert!(tree.contains("src/"));
assert!(tree.contains(" main.rs"));
assert!(tree.contains(" cli.rs"));
}
#[test]
fn test_format_tree_from_paths_nested() {
let paths = vec![
"src/main.rs".to_string(),
"src/utils/helpers.rs".to_string(),
"src/utils/format.rs".to_string(),
];
let tree = format_tree_from_paths(&paths, 3);
assert!(tree.contains("src/"));
assert!(tree.contains(" utils/"));
assert!(tree.contains(" helpers.rs"));
assert!(tree.contains(" format.rs"));
}
#[test]
fn test_format_tree_from_paths_depth_limit() {
let paths = vec![
"a/b/c/d/deep.txt".to_string(),
"a/shallow.txt".to_string(),
"top.txt".to_string(),
];
let tree = format_tree_from_paths(&paths, 1);
assert!(tree.contains("top.txt"));
assert!(tree.contains("a/"));
assert!(tree.contains(" shallow.txt"));
assert!(!tree.contains("deep.txt"));
assert!(!tree.contains("b/"));
}
#[test]
fn test_format_tree_from_paths_empty() {
let paths: Vec<String> = vec![];
let tree = format_tree_from_paths(&paths, 3);
assert!(tree.is_empty());
}
#[test]
fn test_format_tree_from_paths_root_files_only() {
let paths = vec![
"Cargo.lock".to_string(),
"Cargo.toml".to_string(),
"README.md".to_string(),
];
let tree = format_tree_from_paths(&paths, 3);
assert!(!tree.contains('/'));
assert!(tree.contains("Cargo.lock"));
assert!(tree.contains("Cargo.toml"));
assert!(tree.contains("README.md"));
}
#[test]
fn test_format_tree_from_paths_depth_zero() {
let paths = vec!["README.md".to_string(), "src/main.rs".to_string()];
let tree = format_tree_from_paths(&paths, 0);
assert!(tree.contains("README.md"));
assert!(!tree.contains("main.rs"));
}
#[test]
fn test_format_tree_dir_printed_once() {
let paths = vec![
"src/a.rs".to_string(),
"src/b.rs".to_string(),
"src/c.rs".to_string(),
];
let tree = format_tree_from_paths(&paths, 3);
assert_eq!(tree.matches("src/").count(), 1);
}
#[test]
fn test_build_project_tree_runs() {
let tree = build_project_tree(3);
assert!(!tree.is_empty());
}
#[test]
fn test_tree_command_recognized() {
assert!(!is_unknown_command("/tree"));
assert!(!is_unknown_command("/tree 2"));
assert!(!is_unknown_command("/tree 5"));
}
#[test]
fn test_fix_command_recognized() {
assert!(!is_unknown_command("/fix"));
assert!(
KNOWN_COMMANDS.contains(&"/fix"),
"/fix should be in KNOWN_COMMANDS"
);
}
#[test]
fn test_run_health_checks_full_output_returns_results() {
let project_type = detect_project_type(&std::env::current_dir().unwrap());
assert_eq!(project_type, ProjectType::Rust);
let results = run_health_checks_full_output(&project_type);
assert!(
!results.is_empty(),
"Should return at least one check result"
);
for (name, passed, _output) in &results {
assert!(!name.is_empty(), "Check name should not be empty");
if *name == "build" {
assert!(passed, "cargo build should pass in test environment");
}
}
}
#[test]
fn test_build_fix_prompt_with_failures() {
let failures = vec![
(
"build",
"error[E0308]: mismatched types\n --> src/main.rs:42",
),
(
"clippy",
"warning: unused variable `x`\n --> src/lib.rs:10",
),
];
let prompt = build_fix_prompt(&failures);
assert!(prompt.contains("build"), "Prompt should mention build");
assert!(prompt.contains("clippy"), "Prompt should mention clippy");
assert!(
prompt.contains("error[E0308]"),
"Prompt should include build error"
);
assert!(
prompt.contains("unused variable"),
"Prompt should include clippy warning"
);
}
#[test]
fn test_build_fix_prompt_empty_failures() {
let failures: Vec<(&str, &str)> = vec![];
let prompt = build_fix_prompt(&failures);
assert!(
prompt.is_empty() || prompt.contains("Fix"),
"Empty failures should produce empty or minimal prompt"
);
}
#[test]
fn test_test_command_recognized() {
assert!(!is_unknown_command("/test"));
assert!(
KNOWN_COMMANDS.contains(&"/test"),
"/test should be in KNOWN_COMMANDS"
);
}
#[test]
fn test_test_command_for_rust_project() {
let cmd = test_command_for_project(&ProjectType::Rust);
assert!(cmd.is_some(), "Rust project should have a test command");
let (label, args) = cmd.unwrap();
assert!(
label.contains("cargo"),
"Rust test label should mention cargo"
);
assert_eq!(args[0], "cargo");
assert!(args.contains(&"test"));
}
#[test]
fn test_test_command_for_node_project() {
let cmd = test_command_for_project(&ProjectType::Node);
assert!(cmd.is_some(), "Node project should have a test command");
let (label, args) = cmd.unwrap();
assert!(label.contains("npm"), "Node test label should mention npm");
assert_eq!(args[0], "npm");
assert!(args.contains(&"test"));
}
#[test]
fn test_test_command_for_python_project() {
let cmd = test_command_for_project(&ProjectType::Python);
assert!(cmd.is_some(), "Python project should have a test command");
let (label, _args) = cmd.unwrap();
assert!(
label.contains("pytest"),
"Python test label should mention pytest"
);
}
#[test]
fn test_test_command_for_go_project() {
let cmd = test_command_for_project(&ProjectType::Go);
assert!(cmd.is_some(), "Go project should have a test command");
let (label, args) = cmd.unwrap();
assert!(label.contains("go"), "Go test label should mention go");
assert_eq!(args[0], "go");
assert!(args.contains(&"test"));
}
#[test]
fn test_test_command_for_make_project() {
let cmd = test_command_for_project(&ProjectType::Make);
assert!(cmd.is_some(), "Make project should have a test command");
let (label, args) = cmd.unwrap();
assert!(
label.contains("make"),
"Make test label should mention make"
);
assert_eq!(args[0], "make");
assert!(args.contains(&"test"));
}
#[test]
fn test_test_command_for_unknown_project() {
let cmd = test_command_for_project(&ProjectType::Unknown);
assert!(
cmd.is_none(),
"Unknown project should not have a test command"
);
}
#[test]
fn test_lint_command_recognized() {
assert!(!is_unknown_command("/lint"));
assert!(
KNOWN_COMMANDS.contains(&"/lint"),
"/lint should be in KNOWN_COMMANDS"
);
}
#[test]
fn test_lint_command_for_rust_project() {
let cmd = lint_command_for_project(&ProjectType::Rust, LintStrictness::Default);
assert!(cmd.is_some(), "Rust project should have a lint command");
let (label, args) = cmd.unwrap();
assert!(
label.contains("clippy"),
"Rust lint label should mention clippy"
);
assert_eq!(args[0], "cargo");
assert!(args.iter().any(|a| a == "clippy"));
}
#[test]
fn test_lint_command_for_node_project() {
let cmd = lint_command_for_project(&ProjectType::Node, LintStrictness::Default);
assert!(cmd.is_some(), "Node project should have a lint command");
let (label, args) = cmd.unwrap();
assert!(
label.contains("eslint"),
"Node lint label should mention eslint"
);
assert_eq!(args[0], "npx");
assert!(args.iter().any(|a| a == "eslint"));
}
#[test]
fn test_lint_command_for_python_project() {
let cmd = lint_command_for_project(&ProjectType::Python, LintStrictness::Default);
assert!(cmd.is_some(), "Python project should have a lint command");
let (label, _args) = cmd.unwrap();
assert!(
label.contains("ruff"),
"Python lint label should mention ruff"
);
}
#[test]
fn test_lint_command_for_go_project() {
let cmd = lint_command_for_project(&ProjectType::Go, LintStrictness::Default);
assert!(cmd.is_some(), "Go project should have a lint command");
let (label, args) = cmd.unwrap();
assert!(
label.contains("golangci-lint"),
"Go lint label should mention golangci-lint"
);
assert_eq!(args[0], "golangci-lint");
}
#[test]
fn test_lint_command_for_make_project() {
let cmd = lint_command_for_project(&ProjectType::Make, LintStrictness::Default);
assert!(cmd.is_none(), "Make project should not have a lint command");
}
#[test]
fn test_lint_command_for_unknown_project() {
let cmd = lint_command_for_project(&ProjectType::Unknown, LintStrictness::Default);
assert!(
cmd.is_none(),
"Unknown project should not have a lint command"
);
}
#[test]
fn test_lint_pedantic_adds_flag() {
let cmd = lint_command_for_project(&ProjectType::Rust, LintStrictness::Pedantic);
let (label, args) = cmd.unwrap();
assert!(
label.contains("-W clippy::pedantic"),
"Pedantic label should contain -W clippy::pedantic, got: {label}"
);
assert!(
args.iter().any(|a| a == "clippy::pedantic"),
"Pedantic args should contain clippy::pedantic"
);
}
#[test]
fn test_lint_strict_adds_both_flags() {
let cmd = lint_command_for_project(&ProjectType::Rust, LintStrictness::Strict);
let (label, args) = cmd.unwrap();
assert!(
label.contains("-W clippy::pedantic"),
"Strict label should contain -W clippy::pedantic, got: {label}"
);
assert!(
label.contains("-W clippy::nursery"),
"Strict label should contain -W clippy::nursery, got: {label}"
);
assert!(
args.iter().any(|a| a == "clippy::pedantic"),
"Strict args should contain clippy::pedantic"
);
assert!(
args.iter().any(|a| a == "clippy::nursery"),
"Strict args should contain clippy::nursery"
);
}
#[test]
fn test_lint_default_no_extra_flags() {
let cmd = lint_command_for_project(&ProjectType::Rust, LintStrictness::Default);
let (label, args) = cmd.unwrap();
assert!(
!label.contains("clippy::pedantic"),
"Default should not contain clippy::pedantic"
);
assert!(
!label.contains("clippy::nursery"),
"Default should not contain clippy::nursery"
);
assert!(
!args.iter().any(|a| a == "clippy::pedantic"),
"Default args should not contain clippy::pedantic"
);
}
#[test]
fn test_lint_strictness_ignored_for_non_rust() {
let default = lint_command_for_project(&ProjectType::Node, LintStrictness::Default);
let pedantic = lint_command_for_project(&ProjectType::Node, LintStrictness::Pedantic);
let strict = lint_command_for_project(&ProjectType::Node, LintStrictness::Strict);
assert_eq!(default, pedantic);
assert_eq!(default, strict);
}
#[test]
fn scan_for_unsafe_finds_blocks() {
let content = r#"
fn main() {
unsafe {
std::ptr::null::<u8>();
}
}
"#;
let results = scan_for_unsafe("test.rs", content);
assert_eq!(results.len(), 1);
assert_eq!(results[0].kind, UnsafeKind::Block);
assert_eq!(results[0].line_number, 3);
assert_eq!(results[0].file, "test.rs");
}
#[test]
fn scan_for_unsafe_finds_functions() {
let content = r#"
unsafe fn dangerous() {
// do something dangerous
}
"#;
let results = scan_for_unsafe("test.rs", content);
assert_eq!(results.len(), 1);
assert_eq!(results[0].kind, UnsafeKind::Function);
assert_eq!(results[0].line_number, 2);
}
#[test]
fn scan_for_unsafe_finds_impl() {
let content = r#"
unsafe impl Send for MyType {}
"#;
let results = scan_for_unsafe("test.rs", content);
assert_eq!(results.len(), 1);
assert_eq!(results[0].kind, UnsafeKind::Impl);
}
#[test]
fn scan_for_unsafe_finds_trait() {
let content = r#"
unsafe trait MyTrait {}
"#;
let results = scan_for_unsafe("test.rs", content);
assert_eq!(results.len(), 1);
assert_eq!(results[0].kind, UnsafeKind::Trait);
}
#[test]
fn scan_for_unsafe_ignores_comments() {
let content = r#"
// unsafe { this is a comment }
fn safe() {}
"#;
let results = scan_for_unsafe("test.rs", content);
assert!(results.is_empty());
}
#[test]
fn scan_for_unsafe_ignores_strings() {
let content = r#"
let s = "unsafe { not real code }";
"#;
let results = scan_for_unsafe("test.rs", content);
assert!(results.is_empty());
}
#[test]
fn scan_for_unsafe_no_occurrences() {
let content = r#"
fn main() {
println!("hello world");
}
"#;
let results = scan_for_unsafe("test.rs", content);
assert!(results.is_empty());
}
#[test]
fn scan_for_unsafe_multiple_occurrences() {
let content = r#"
unsafe fn one() {}
fn two() {
unsafe {
// block
}
}
unsafe impl Send for Foo {}
"#;
let results = scan_for_unsafe("test.rs", content);
assert_eq!(results.len(), 3);
assert_eq!(results[0].kind, UnsafeKind::Function);
assert_eq!(results[1].kind, UnsafeKind::Block);
assert_eq!(results[2].kind, UnsafeKind::Impl);
}
#[test]
fn detects_forbid_attribute() {
let content = "#![forbid(unsafe_code)]\nfn main() {}";
assert_eq!(has_unsafe_code_attribute(content), Some("forbid"));
}
#[test]
fn detects_deny_attribute() {
let content = "#![deny(unsafe_code)]\nfn main() {}";
assert_eq!(has_unsafe_code_attribute(content), Some("deny"));
}
#[test]
fn no_attribute_returns_none() {
let content = "fn main() {}";
assert_eq!(has_unsafe_code_attribute(content), None);
}
#[test]
fn ignores_commented_attribute() {
let content = "// #![forbid(unsafe_code)]\nfn main() {}";
assert_eq!(has_unsafe_code_attribute(content), None);
}
#[test]
fn lint_unsafe_in_subcommands() {
assert!(
LINT_SUBCOMMANDS.contains(&"unsafe"),
"LINT_SUBCOMMANDS should contain 'unsafe'"
);
}
}