use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use fallow_config::{ResolvedConfig, WorkspaceInfo};
use fallow_output::{PackageBaselineStatus, RequestName, RequestOutcome};
use fallow_types::duplicates::DuplicationReport;
use fallow_types::results::AnalysisResults;
use rustc_hash::FxHashSet;
use crate::changed_files::{ChangedFilesError, NormalizedChangedFiles};
use crate::package_baselines::{PackageBaselineError, PackageChangeScope};
const PACKAGE_BASELINES_REQUEST: &str = "workspaces.changedSince";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ChangeScopeOwner {
#[default]
Run,
Caller,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ChangeScopeRequest<'a> {
pub owner: ChangeScopeOwner,
pub global_ref: bool,
pub files: Option<&'a FxHashSet<PathBuf>>,
pub cache: Option<&'a PackageBaselineCache>,
pub no_package_baselines: bool,
}
impl ChangeScopeRequest<'_> {
#[must_use]
pub fn reads_package_baselines(&self, config: &ResolvedConfig) -> bool {
self.owner == ChangeScopeOwner::Run
&& !self.no_package_baselines
&& !self.global_ref
&& self.files.is_none()
&& !config.workspace_changed_since.is_empty()
}
}
#[derive(Debug, Clone)]
struct ResolvedPackages {
packages: Option<PackageChangeScope>,
outcome: RequestOutcome,
}
#[derive(Debug, Default)]
pub struct PackageBaselineCache(OnceLock<Result<ResolvedPackages, PackageBaselineError>>);
impl PackageBaselineCache {
#[must_use]
pub const fn new() -> Self {
Self(OnceLock::new())
}
#[must_use]
pub fn is_resolved(&self) -> bool {
self.0.get().is_some()
}
#[must_use]
pub fn request_outcome(&self) -> Option<RequestOutcome> {
match self.0.get()? {
Ok(resolved) => Some(resolved.outcome.clone()),
Err(_) => None,
}
}
fn resolve(
&self,
config: &ResolvedConfig,
workspaces: &[WorkspaceInfo],
) -> Result<ResolvedPackages, PackageBaselineError> {
self.0
.get_or_init(|| resolve_packages(config, workspaces))
.clone()
}
}
fn resolve_packages(
config: &ResolvedConfig,
workspaces: &[WorkspaceInfo],
) -> Result<ResolvedPackages, PackageBaselineError> {
match PackageChangeScope::resolve(&config.root, &config.workspace_changed_since, workspaces) {
Ok(packages) => Ok(ResolvedPackages {
packages,
outcome: RequestOutcome::applied(
RequestName::PackageBaselines,
PACKAGE_BASELINES_REQUEST,
),
}),
Err(PackageBaselineError::Git {
key,
reference,
source,
}) if !matches!(source, ChangedFilesError::InvalidRef(_)) => {
let cause = source
.describe()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
Ok(stood_down(
source.reason(),
&format!("the ref '{reference}' for '{key}' did not resolve ({cause})"),
"Fetch the ref with full history, or map the package to a ref Git can resolve.",
))
}
Err(PackageBaselineError::UnknownWorkspace { key, suggestion }) => {
let hint =
suggestion.map_or_else(String::new, |root| format!(" (did you mean '{root}'?)"));
Ok(stood_down(
"unknown-workspace",
&format!("'{key}' names no workspace package of this project{hint}"),
"Use a workspace root as `fallow list --workspaces` prints it.",
))
}
Err(err) => Err(err),
}
}
fn stood_down(reason: &str, cause: &str, remedy: &str) -> ResolvedPackages {
ResolvedPackages {
packages: None,
outcome: RequestOutcome::not_applied(
RequestName::PackageBaselines,
PACKAGE_BASELINES_REQUEST,
reason,
format!(
"workspaces.changedSince was ignored because {cause}, so this report covers \
every workspace package in full scope. {remedy}"
),
),
}
}
#[derive(Debug, Clone, Default)]
pub struct ChangeScope {
kind: ChangeScopeKind,
global_ref: bool,
outcome: Option<RequestOutcome>,
}
#[derive(Debug, Clone, Default)]
enum ChangeScopeKind {
#[default]
Full,
Files(NormalizedChangedFiles),
Packages(PackageChangeScope),
}
impl ChangeScope {
pub fn resolve(
request: ChangeScopeRequest<'_>,
config: &ResolvedConfig,
workspaces: &[WorkspaceInfo],
) -> Result<Self, PackageBaselineError> {
let global_ref = request.global_ref || request.files.is_some();
if let Some(files) = request.files {
return Ok(Self::changed_files(files));
}
if !request.reads_package_baselines(config) {
return Ok(Self {
global_ref,
..Self::default()
});
}
let resolved = match request.cache {
Some(cache) => cache.resolve(config, workspaces)?,
None => resolve_packages(config, workspaces)?,
};
Ok(Self {
kind: resolved
.packages
.map_or(ChangeScopeKind::Full, ChangeScopeKind::Packages),
global_ref,
outcome: Some(resolved.outcome),
})
}
#[must_use]
pub fn changed_files(files: &FxHashSet<PathBuf>) -> Self {
Self {
kind: ChangeScopeKind::Files(NormalizedChangedFiles::new(files)),
global_ref: true,
outcome: None,
}
}
#[must_use]
pub const fn scope_reason(&self) -> Option<fallow_output::ScopeReason> {
match (&self.kind, self.global_ref) {
(ChangeScopeKind::Packages(_), _) => Some(fallow_output::ScopeReason::PackageBaselines),
(_, true) => Some(fallow_output::ScopeReason::ChangedSince),
(_, false) => None,
}
}
#[must_use]
pub const fn packages(&self) -> Option<&PackageChangeScope> {
match &self.kind {
ChangeScopeKind::Packages(packages) => Some(packages),
ChangeScopeKind::Full | ChangeScopeKind::Files(_) => None,
}
}
#[must_use]
pub const fn request_outcome(&self) -> Option<&RequestOutcome> {
self.outcome.as_ref()
}
#[must_use]
pub fn stand_down_message(&self) -> Option<&str> {
self.outcome
.as_ref()
.filter(|outcome| outcome.status != fallow_output::RequestStatus::Applied)
.and_then(|outcome| outcome.message.as_deref())
}
#[must_use]
pub fn package_baselines(&self) -> Vec<PackageBaselineStatus> {
self.packages().map_or_else(Vec::new, |packages| {
package_baseline_statuses(std::slice::from_ref(packages), packages.project_root())
})
}
#[must_use]
pub fn contains(&self, path: &Path) -> bool {
use crate::changed_files::ChangedPathScope as _;
match &self.kind {
ChangeScopeKind::Full => true,
ChangeScopeKind::Files(files) => files.contains(path),
ChangeScopeKind::Packages(packages) => packages.contains(path),
}
}
pub(crate) fn retain_dead_code(&self, results: &mut AnalysisResults) {
match &self.kind {
ChangeScopeKind::Full => {}
ChangeScopeKind::Files(files) => {
crate::changed_files::filter_results_by_path_scope(results, files);
}
ChangeScopeKind::Packages(packages) => {
crate::changed_files::filter_results_by_path_scope(results, packages);
}
}
}
pub(crate) fn retain_duplication(&self, report: &mut DuplicationReport, root: &Path) {
match &self.kind {
ChangeScopeKind::Full => {}
ChangeScopeKind::Files(files) => {
crate::changed_files::filter_duplication_by_path_scope(report, files, root);
}
ChangeScopeKind::Packages(packages) => {
crate::changed_files::filter_duplication_by_path_scope(report, packages, root);
}
}
}
}
#[must_use]
pub fn first_package_baselines<'a>(
sections: impl IntoIterator<Item = Option<&'a [PackageBaselineStatus]>>,
) -> Vec<PackageBaselineStatus> {
sections
.into_iter()
.flatten()
.find(|rows| !rows.is_empty())
.map_or_else(Vec::new, <[PackageBaselineStatus]>::to_vec)
}
#[must_use]
pub fn package_baseline_statuses(
scopes: &[PackageChangeScope],
root: &Path,
) -> Vec<PackageBaselineStatus> {
let root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let mut rows = scopes
.iter()
.flat_map(|scope| {
let prefix = scope
.project_root()
.strip_prefix(&root)
.map(|relative| {
relative
.components()
.map(|component| component.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
})
.unwrap_or_default();
scope
.configured_baselines()
.map(move |(key, reference)| PackageBaselineStatus {
workspace_root: if prefix.is_empty() {
key.to_owned()
} else {
format!("{prefix}/{key}")
},
reference: reference.to_owned(),
})
})
.collect::<Vec<_>>();
rows.sort_by(|a, b| a.workspace_root.cmp(&b.workspace_root));
rows
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use fallow_config::{FallowConfig, OutputFormat};
use super::*;
fn config_with_map(root: &Path, map: &[(&str, &str)]) -> ResolvedConfig {
let mut config = FallowConfig::default().resolve(
root.to_path_buf(),
OutputFormat::Json,
1,
true,
true,
None,
);
config.workspace_changed_since = map
.iter()
.map(|(key, reference)| ((*key).to_owned(), (*reference).to_owned()))
.collect::<BTreeMap<_, _>>();
config
}
fn unknown_workspace_map(root: &Path) -> ResolvedConfig {
config_with_map(root, &[("packages/missing", "HEAD")])
}
#[test]
fn caller_owned_scope_never_reads_the_package_map() {
let temp = tempfile::tempdir().expect("tempdir");
let config = unknown_workspace_map(temp.path());
let request = ChangeScopeRequest {
owner: ChangeScopeOwner::Caller,
..ChangeScopeRequest::default()
};
assert!(!request.reads_package_baselines(&config));
let scope = ChangeScope::resolve(request, &config, &[]).expect("caller-owned scope");
assert!(scope.scope_reason().is_none());
assert!(scope.package_baselines().is_empty());
assert!(scope.contains(&temp.path().join("packages/a/index.ts")));
}
#[test]
fn a_requested_global_ref_suppresses_the_map_even_without_files() {
let temp = tempfile::tempdir().expect("tempdir");
let config = unknown_workspace_map(temp.path());
let request = ChangeScopeRequest {
global_ref: true,
..ChangeScopeRequest::default()
};
let scope = ChangeScope::resolve(request, &config, &[]).expect("global scope");
assert!(scope.scope_reason().is_some());
assert!(scope.packages().is_none());
}
#[test]
fn a_changed_file_set_wins_over_the_map() {
let temp = tempfile::tempdir().expect("tempdir");
let config = unknown_workspace_map(temp.path());
let changed: FxHashSet<PathBuf> = std::iter::once(temp.path().join("a.ts")).collect();
let request = ChangeScopeRequest {
files: Some(&changed),
..ChangeScopeRequest::default()
};
let scope = ChangeScope::resolve(request, &config, &[]).expect("file scope");
assert!(scope.scope_reason().is_some());
assert!(scope.contains(&temp.path().join("a.ts")));
assert!(!scope.contains(&temp.path().join("b.ts")));
}
#[test]
fn a_key_that_names_no_workspace_stands_the_map_down() {
let temp = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(temp.path().join("packages/web")).expect("package");
let config = config_with_map(temp.path(), &[("packages/wbe", "HEAD")]);
let workspaces = [WorkspaceInfo {
root: temp.path().join("packages/web"),
name: "web".to_owned(),
is_internal_dependency: false,
}];
let scope = ChangeScope::resolve(ChangeScopeRequest::default(), &config, &workspaces)
.expect("an unknown key stands down");
assert!(scope.scope_reason().is_none());
assert!(scope.packages().is_none());
assert!(scope.package_baselines().is_empty());
let outcome = scope.request_outcome().expect("the map was read");
assert_eq!(outcome.status, fallow_output::RequestStatus::NotApplied);
assert_eq!(outcome.reason.as_deref(), Some("unknown-workspace"));
let message = scope.stand_down_message().expect("a stand-down sentence");
assert!(
message.contains("did you mean 'packages/web'?"),
"{message}"
);
}
#[test]
fn a_malformed_key_or_ref_is_invalid_input() {
let temp = tempfile::tempdir().expect("tempdir");
for map in [
[("./packages/web", "HEAD")],
[("packages/web", "-malformed")],
] {
std::fs::create_dir_all(temp.path().join("packages/web")).expect("package");
let config = config_with_map(temp.path(), &map);
let workspaces = [WorkspaceInfo {
root: temp.path().join("packages/web"),
name: "web".to_owned(),
is_internal_dependency: false,
}];
assert!(
ChangeScope::resolve(ChangeScopeRequest::default(), &config, &workspaces).is_err(),
"{map:?}"
);
}
}
#[test]
fn the_run_memo_resolves_the_map_once() {
let temp = tempfile::tempdir().expect("tempdir");
let config = unknown_workspace_map(temp.path());
let cache = PackageBaselineCache::new();
assert!(!cache.is_resolved());
assert!(cache.request_outcome().is_none());
let request = ChangeScopeRequest {
cache: Some(&cache),
..ChangeScopeRequest::default()
};
ChangeScope::resolve(request, &config, &[]).expect("first resolution");
assert!(cache.is_resolved());
let later_config = config_with_map(temp.path(), &[]);
let later = ChangeScope::resolve(
ChangeScopeRequest {
cache: Some(&cache),
..ChangeScopeRequest::default()
},
&config,
&[],
)
.expect("memo hit");
assert_eq!(later.request_outcome(), cache.request_outcome().as_ref());
let empty = ChangeScope::resolve(ChangeScopeRequest::default(), &later_config, &[])
.expect("no map, full scope");
assert!(empty.scope_reason().is_none());
assert!(empty.request_outcome().is_none());
}
}