use camino::{Utf8Path, Utf8PathBuf};
use log::trace;
use std::io::Write;
use crate::cli::ListArgs;
use crate::dirs::{BaseDirs, SystemBaseDirs};
use crate::error::{InstallerError, Result};
use crate::list_output::{format_human, format_json};
use crate::scanner::{InstalledLints, scan_installed};
use crate::stager::default_target_dir;
use crate::toolchain::Toolchain;
pub fn run_list(args: &ListArgs, stdout: &mut dyn Write) -> Result<()> {
run_list_with(args, stdout, detect_active_toolchain)
}
fn run_list_with<F>(args: &ListArgs, stdout: &mut dyn Write, detect_toolchain: F) -> Result<()>
where
F: FnOnce() -> Option<String>,
{
let scan_roots = determine_scan_roots(args.target_dir.as_deref())?;
let mut installed = InstalledLints::default();
for root in scan_roots {
let discovered =
scan_installed(&root).map_err(|e| InstallerError::ScanFailed { source: e })?;
merge_installed(&mut installed, discovered);
}
sort_installed_libraries(&mut installed);
let active_toolchain = detect_toolchain();
let output = if args.json {
format_json(&installed, active_toolchain.as_deref())
} else {
format_human(&installed, active_toolchain.as_deref())
};
writeln!(stdout, "{output}").map_err(|e| InstallerError::WriteFailed { source: e })?;
Ok(())
}
fn merge_installed(target: &mut InstalledLints, discovered: InstalledLints) {
for (toolchain, mut libraries) in discovered.by_toolchain {
let entry = target.by_toolchain.entry(toolchain).or_default();
entry.append(&mut libraries);
}
}
fn sort_installed_libraries(installed: &mut InstalledLints) {
for libraries in installed.by_toolchain.values_mut() {
libraries.sort_by(|left, right| left.crate_name.as_str().cmp(right.crate_name.as_str()));
}
}
fn default_prebuilt_target_dir() -> Option<Utf8PathBuf> {
SystemBaseDirs::new()
.and_then(|dirs| dirs.whitaker_data_dir())
.and_then(|path| Utf8PathBuf::from_path_buf(path).ok())
.map(|path| path.join("lints"))
}
fn determine_scan_roots(cli_target: Option<&Utf8Path>) -> Result<Vec<Utf8PathBuf>> {
if let Some(target) = cli_target {
return Ok(vec![target.to_owned()]);
}
let mut roots = Vec::new();
if let Some(default) = default_target_dir() {
roots.push(default);
}
if let Some(prebuilt) = default_prebuilt_target_dir()
&& !roots.iter().any(|root| root == &prebuilt)
{
roots.push(prebuilt);
}
if roots.is_empty() {
return Err(InstallerError::StagingFailed {
reason: "could not determine any scan roots".to_owned(),
});
}
Ok(roots)
}
pub fn detect_active_toolchain() -> Option<String> {
let cwd = match std::env::current_dir() {
Ok(path) => path,
Err(e) => {
trace!("detect_active_toolchain: failed to get current dir: {e}");
return None;
}
};
let utf8_cwd = match Utf8PathBuf::try_from(cwd) {
Ok(path) => path,
Err(e) => {
trace!("detect_active_toolchain: current dir is not valid UTF-8: {e}");
return None;
}
};
detect_active_toolchain_in(&utf8_cwd)
}
pub(crate) fn detect_active_toolchain_in(dir: &Utf8Path) -> Option<String> {
match Toolchain::detect(dir) {
Ok(tc) => Some(tc.channel().to_owned()),
Err(e) => {
trace!("detect_active_toolchain_in: toolchain detection failed: {e}");
None
}
}
}
pub fn determine_target_dir(cli_target: Option<&Utf8Path>) -> Result<Utf8PathBuf> {
determine_target_dir_with(cli_target, default_target_dir)
}
fn determine_target_dir_with<F>(cli_target: Option<&Utf8Path>, default_fn: F) -> Result<Utf8PathBuf>
where
F: FnOnce() -> Option<Utf8PathBuf>,
{
cli_target
.map(Utf8Path::to_owned)
.or_else(default_fn)
.ok_or_else(|| InstallerError::StagingFailed {
reason: "could not determine default target directory".to_owned(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::{fixture, rstest};
use std::fs;
use tempfile::TempDir;
struct TempTarget {
_temp: TempDir,
path: Utf8PathBuf,
}
#[fixture]
fn temp_target() -> TempTarget {
let temp = TempDir::new().expect("failed to create temp dir");
let path = Utf8PathBuf::try_from(temp.path().to_owned()).expect("non-UTF8 temp path");
TempTarget { _temp: temp, path }
}
struct FailingWriter;
impl std::io::Write for FailingWriter {
fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
Err(std::io::Error::other("simulated write failure"))
}
fn flush(&mut self) -> std::io::Result<()> {
Err(std::io::Error::other("simulated flush failure"))
}
}
#[derive(Debug, Clone, Copy)]
enum MockLibraryKind {
Local,
Prebuilt { target: &'static str },
}
impl MockLibraryKind {
fn library_dir(&self, target_dir: &Utf8Path, toolchain: &str) -> Utf8PathBuf {
match self {
Self::Local => target_dir.join(toolchain).join("release"),
Self::Prebuilt { target } => target_dir.join(toolchain).join(target).join("lib"),
}
}
fn content(&self) -> &'static [u8] {
match self {
Self::Local => b"mock library",
Self::Prebuilt { .. } => b"mock prebuilt library",
}
}
}
fn create_mock_library_internal(target_dir: &Utf8Path, toolchain: &str, kind: MockLibraryKind) {
use crate::builder::{library_extension, library_prefix};
let lib_dir = kind.library_dir(target_dir, toolchain);
fs::create_dir_all(&lib_dir).expect("failed to create target library directory");
let filename = format!(
"{}whitaker_suite@{toolchain}{}",
library_prefix(),
library_extension()
);
let error_msg = match kind {
MockLibraryKind::Local => "failed to create mock library",
MockLibraryKind::Prebuilt { .. } => "failed to create prebuilt mock library",
};
fs::write(lib_dir.join(filename), kind.content()).expect(error_msg);
}
fn create_mock_library(target_dir: &Utf8Path, toolchain: &str) {
create_mock_library_internal(target_dir, toolchain, MockLibraryKind::Local);
}
fn create_mock_prebuilt_library(target_dir: &Utf8Path, toolchain: &str, target: &'static str) {
create_mock_library_internal(target_dir, toolchain, MockLibraryKind::Prebuilt { target });
}
#[rstest]
fn run_list_outputs_human_readable_format(temp_target: TempTarget) {
let args = ListArgs {
json: false,
target_dir: Some(temp_target.path.clone()),
};
let mut stdout = Vec::new();
let result = run_list_with(&args, &mut stdout, || None);
assert!(result.is_ok(), "expected success, got: {result:?}");
let output = String::from_utf8_lossy(&stdout);
assert!(output.contains("No lints installed"), "got: {output}");
}
#[rstest]
#[case::json_format(true, &["toolchains", "\"active\""])]
#[case::human_format(false, &["nightly-2026-05-28", "whitaker_suite"])]
fn run_list_with_installed_library_includes_expected_output(
temp_target: TempTarget,
#[case] json: bool,
#[case] expected: &[&str],
) {
create_mock_library(&temp_target.path, "nightly-2026-05-28");
let args = ListArgs {
json,
target_dir: Some(temp_target.path.clone()),
};
let mut stdout = Vec::new();
let result = run_list_with(&args, &mut stdout, || Some("nightly-2026-05-28".to_owned()));
assert!(result.is_ok(), "expected success, got: {result:?}");
let output = String::from_utf8_lossy(&stdout);
for needle in expected {
assert!(
output.contains(needle),
"expected '{needle}' in output: {output}"
);
}
}
#[rstest]
fn run_list_finds_prebuilt_layout_libraries(temp_target: TempTarget) {
create_mock_prebuilt_library(
&temp_target.path,
"nightly-2026-05-28",
"x86_64-unknown-linux-gnu",
);
let args = ListArgs {
json: false,
target_dir: Some(temp_target.path.clone()),
};
let mut stdout = Vec::new();
let result = run_list_with(&args, &mut stdout, || Some("nightly-2026-05-28".to_owned()));
assert!(result.is_ok(), "expected success, got: {result:?}");
let output = String::from_utf8_lossy(&stdout);
assert!(output.contains("nightly-2026-05-28"), "got: {output}");
assert!(output.contains("whitaker_suite"), "got: {output}");
}
#[rstest]
fn run_list_returns_write_failed_on_stdout_error(temp_target: TempTarget) {
let args = ListArgs {
json: false,
target_dir: Some(temp_target.path.clone()),
};
let mut failing_stdout = FailingWriter;
let result = run_list_with(&args, &mut failing_stdout, || None);
let err = result.expect_err("expected error on write failure");
assert!(
matches!(err, InstallerError::WriteFailed { .. }),
"expected WriteFailed error, got: {err:?}"
);
}
#[rstest]
fn detect_active_toolchain_in_returns_none_when_no_toolchain_file(temp_target: TempTarget) {
let result = detect_active_toolchain_in(&temp_target.path);
assert!(
result.is_none(),
"expected None for directory without rust-toolchain.toml"
);
}
#[rstest]
fn detect_active_toolchain_in_returns_channel_when_toolchain_file_exists(
temp_target: TempTarget,
) {
let toolchain_content = r#"[toolchain]
channel = "nightly-2026-05-28"
"#;
fs::write(
temp_target.path.join("rust-toolchain.toml"),
toolchain_content,
)
.expect("failed to write rust-toolchain.toml");
let result = detect_active_toolchain_in(&temp_target.path);
assert_eq!(result, Some("nightly-2026-05-28".to_owned()));
}
#[rstest]
fn determine_target_dir_returns_cli_value_when_provided(temp_target: TempTarget) {
let result = determine_target_dir_with(Some(&temp_target.path), || None);
assert!(result.is_ok(), "expected success, got: {result:?}");
assert_eq!(result.expect("already checked"), temp_target.path);
}
#[rstest]
fn determine_target_dir_falls_back_to_default_when_cli_is_none(temp_target: TempTarget) {
let default_path = temp_target.path.clone();
let result = determine_target_dir_with(None, || Some(default_path.clone()));
assert!(result.is_ok(), "expected success, got: {result:?}");
assert_eq!(result.expect("already checked"), default_path);
}
#[test]
fn determine_target_dir_returns_error_when_no_default_available() {
let result = determine_target_dir_with(None, || None);
let err = result.expect_err("expected error when no default");
assert!(
matches!(err, InstallerError::StagingFailed { .. }),
"expected StagingFailed error, got: {err:?}"
);
}
#[rstest]
fn determine_target_dir_prefers_cli_over_default(temp_target: TempTarget) {
let cli_path = temp_target.path.clone();
let default_path = temp_target.path.join("should_not_be_used");
let result = determine_target_dir_with(Some(&cli_path), || Some(default_path));
assert!(result.is_ok(), "expected success, got: {result:?}");
assert_eq!(result.expect("already checked"), cli_path);
}
}