use anyhow::{format_err, Result};
use crate::package::analysis::{self, Analysis, PathType};
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct SelectedTarget {
pub absolute_path: std::path::PathBuf,
pub relative_path: std::path::PathBuf,
pub file_hash: crate::schema::FileHash,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct CandidateFile {
pub relative_path: std::path::PathBuf,
pub line_count: usize,
pub already_reviewed: bool,
}
pub fn resolve_target_path(
workspace_path: &std::path::Path,
target_file: &str,
) -> Result<SelectedTarget> {
let target_path = std::path::PathBuf::from(target_file);
let target_path = if target_path.is_absolute() {
target_path
} else {
workspace_path.join(target_path)
};
if !target_path.is_file() {
return Err(format_err!(
"Target file not found: {}",
target_path.display()
));
}
let target_relative = target_path
.strip_prefix(workspace_path)
.unwrap_or(target_path.as_path())
.to_path_buf();
selected_target(target_path, target_relative)
}
pub fn resolve_target_paths(
workspace_path: &std::path::Path,
target_files: &[String],
) -> Result<Vec<SelectedTarget>> {
let mut seen = std::collections::BTreeSet::new();
let mut targets = Vec::new();
for target_file in target_files {
let target = resolve_target_path(workspace_path, target_file)?;
if seen.insert(target.relative_path.clone()) {
targets.push(target);
}
}
Ok(targets)
}
pub fn selected_target(
absolute_path: std::path::PathBuf,
relative_path: std::path::PathBuf,
) -> Result<SelectedTarget> {
if !absolute_path.is_file() {
return Err(format_err!(
"Target path is not a file: {}",
absolute_path.display()
));
}
let hash = analysis::file_blake3_digest(&absolute_path)?;
Ok(SelectedTarget {
absolute_path,
relative_path,
file_hash: crate::schema::FileHash::blake3(hash),
})
}
pub fn candidate_files(
analysis: &Analysis,
already_reviewed_paths: &std::collections::BTreeSet<std::path::PathBuf>,
) -> Vec<CandidateFile> {
candidate_files_with_policy(
analysis,
already_reviewed_paths,
&crate::extension::ReviewTargetPolicy::default(),
)
}
pub fn candidate_files_with_policy(
analysis: &Analysis,
already_reviewed_paths: &std::collections::BTreeSet<std::path::PathBuf>,
policy: &crate::extension::ReviewTargetPolicy,
) -> Vec<CandidateFile> {
let mut candidates = Vec::new();
for (path, entry) in analysis.iter() {
if matches!(entry.path_type, PathType::File) && !policy.excludes_path(path) {
candidates.push(CandidateFile {
relative_path: path.clone(),
line_count: entry.line_count,
already_reviewed: already_reviewed_paths.contains(path),
});
}
}
sort_candidates(&mut candidates);
candidates
}
pub fn sort_candidates(candidates: &mut [CandidateFile]) {
candidates.sort_by(|a, b| {
a.already_reviewed
.cmp(&b.already_reviewed)
.then_with(|| b.line_count.cmp(&a.line_count))
.then_with(|| a.relative_path.cmp(&b.relative_path))
});
}
pub fn all_candidates_reviewed(candidates: &[CandidateFile]) -> bool {
!candidates.is_empty()
&& candidates
.iter()
.all(|candidate| candidate.already_reviewed)
}
pub fn select_first_candidate(
workspace_path: &std::path::Path,
candidates: &[CandidateFile],
) -> Result<SelectedTarget> {
let candidate = candidates
.first()
.ok_or(format_err!("No files found to review."))?;
let target_relative = candidate.relative_path.clone();
let target_path = workspace_path.join(&target_relative);
selected_target(target_path, target_relative)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sort_candidates_prefers_unreviewed_files() {
let mut candidates = vec![
candidate_file("already-reviewed-large.js", 300, true),
candidate_file("unreviewed-small.js", 50, false),
candidate_file("unreviewed-large.js", 200, false),
candidate_file("already-reviewed-small.js", 20, true),
];
sort_candidates(&mut candidates);
let paths = candidates
.iter()
.map(|candidate| candidate.relative_path.to_string_lossy().to_string())
.collect::<Vec<_>>();
assert_eq!(
paths,
vec![
"unreviewed-large.js",
"unreviewed-small.js",
"already-reviewed-large.js",
"already-reviewed-small.js",
]
);
}
#[test]
fn resolve_target_path_records_blake3_file_hash() -> Result<()> {
let tmp = tempfile::tempdir()?;
let workspace = tmp.path().to_path_buf();
let contents = b"console.log('review me');\n";
std::fs::write(workspace.join("index.js"), contents)?;
let target = resolve_target_path(&workspace, "index.js")?;
let expected_hash = blake3::hash(contents).to_hex().as_str().to_string();
assert_eq!(target.relative_path, std::path::PathBuf::from("index.js"));
assert_eq!(
target.file_hash,
crate::schema::FileHash::blake3(expected_hash)
);
Ok(())
}
#[test]
fn candidate_files_with_policy_excludes_exact_paths() {
let analysis = analysis_for_paths(&[
(".cargo_vcs_info.json", 1),
("Cargo.lock", 100),
("Cargo.toml", 20),
("src/lib.rs", 50),
]);
let policy = crate::extension::ReviewTargetPolicy {
excluded_exact_paths: vec![
".cargo_vcs_info.json".to_string(),
"Cargo.lock".to_string(),
],
};
let candidates =
candidate_files_with_policy(&analysis, &std::collections::BTreeSet::new(), &policy);
let paths = candidates
.iter()
.map(|candidate| candidate.relative_path.to_string_lossy().to_string())
.collect::<Vec<_>>();
assert_eq!(paths, vec!["src/lib.rs", "Cargo.toml"]);
}
fn candidate_file(path: &str, line_count: usize, already_reviewed: bool) -> CandidateFile {
CandidateFile {
relative_path: std::path::PathBuf::from(path),
line_count,
already_reviewed,
}
}
fn analysis_for_paths(paths: &[(&str, usize)]) -> Analysis {
paths
.iter()
.map(|(path, line_count)| {
(
std::path::PathBuf::from(path),
analysis::PathAnalysis {
path_type: PathType::File,
line_count: *line_count,
},
)
})
.collect()
}
}