1use std::path::{Path, PathBuf};
27use std::sync::OnceLock;
28
29use fallow_config::{ResolvedConfig, WorkspaceInfo};
30use fallow_output::{PackageBaselineStatus, RequestName, RequestOutcome};
31use fallow_types::duplicates::DuplicationReport;
32use fallow_types::results::AnalysisResults;
33use rustc_hash::FxHashSet;
34
35use crate::changed_files::{ChangedFilesError, NormalizedChangedFiles};
36use crate::package_baselines::{PackageBaselineError, PackageChangeScope};
37
38const PACKAGE_BASELINES_REQUEST: &str = "workspaces.changedSince";
40
41#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
43pub enum ChangeScopeOwner {
44 #[default]
47 Run,
48 Caller,
54}
55
56#[derive(Debug, Clone, Copy, Default)]
58pub struct ChangeScopeRequest<'a> {
59 pub owner: ChangeScopeOwner,
61 pub global_ref: bool,
65 pub files: Option<&'a FxHashSet<PathBuf>>,
68 pub cache: Option<&'a PackageBaselineCache>,
72 pub no_package_baselines: bool,
76}
77
78impl ChangeScopeRequest<'_> {
79 #[must_use]
83 pub fn reads_package_baselines(&self, config: &ResolvedConfig) -> bool {
84 self.owner == ChangeScopeOwner::Run
85 && !self.no_package_baselines
86 && !self.global_ref
87 && self.files.is_none()
88 && !config.workspace_changed_since.is_empty()
89 }
90}
91
92#[derive(Debug, Clone)]
94struct ResolvedPackages {
95 packages: Option<PackageChangeScope>,
96 outcome: RequestOutcome,
97}
98
99#[derive(Debug, Default)]
107pub struct PackageBaselineCache(OnceLock<Result<ResolvedPackages, PackageBaselineError>>);
108
109impl PackageBaselineCache {
110 #[must_use]
112 pub const fn new() -> Self {
113 Self(OnceLock::new())
114 }
115
116 #[must_use]
118 pub fn is_resolved(&self) -> bool {
119 self.0.get().is_some()
120 }
121
122 #[must_use]
125 pub fn request_outcome(&self) -> Option<RequestOutcome> {
126 match self.0.get()? {
127 Ok(resolved) => Some(resolved.outcome.clone()),
128 Err(_) => None,
129 }
130 }
131
132 fn resolve(
133 &self,
134 config: &ResolvedConfig,
135 workspaces: &[WorkspaceInfo],
136 ) -> Result<ResolvedPackages, PackageBaselineError> {
137 self.0
138 .get_or_init(|| resolve_packages(config, workspaces))
139 .clone()
140 }
141}
142
143fn resolve_packages(
144 config: &ResolvedConfig,
145 workspaces: &[WorkspaceInfo],
146) -> Result<ResolvedPackages, PackageBaselineError> {
147 match PackageChangeScope::resolve(&config.root, &config.workspace_changed_since, workspaces) {
148 Ok(packages) => Ok(ResolvedPackages {
149 packages,
150 outcome: RequestOutcome::applied(
151 RequestName::PackageBaselines,
152 PACKAGE_BASELINES_REQUEST,
153 ),
154 }),
155 Err(PackageBaselineError::Git {
156 key,
157 reference,
158 source,
159 }) if !matches!(source, ChangedFilesError::InvalidRef(_)) => {
160 let cause = source
161 .describe()
162 .split_whitespace()
163 .collect::<Vec<_>>()
164 .join(" ");
165 Ok(stood_down(
166 source.reason(),
167 &format!("the ref '{reference}' for '{key}' did not resolve ({cause})"),
168 "Fetch the ref with full history, or map the package to a ref Git can resolve.",
169 ))
170 }
171 Err(PackageBaselineError::UnknownWorkspace { key, suggestion }) => {
172 let hint =
173 suggestion.map_or_else(String::new, |root| format!(" (did you mean '{root}'?)"));
174 Ok(stood_down(
175 "unknown-workspace",
176 &format!("'{key}' names no workspace package of this project{hint}"),
177 "Use a workspace root as `fallow list --workspaces` prints it.",
178 ))
179 }
180 Err(err) => Err(err),
181 }
182}
183
184fn stood_down(reason: &str, cause: &str, remedy: &str) -> ResolvedPackages {
187 ResolvedPackages {
188 packages: None,
189 outcome: RequestOutcome::not_applied(
190 RequestName::PackageBaselines,
191 PACKAGE_BASELINES_REQUEST,
192 reason,
193 format!(
194 "workspaces.changedSince was ignored because {cause}, so this report covers \
195 every workspace package in full scope. {remedy}"
196 ),
197 ),
198 }
199}
200
201#[derive(Debug, Clone, Default)]
203pub struct ChangeScope {
204 kind: ChangeScopeKind,
205 global_ref: bool,
206 outcome: Option<RequestOutcome>,
207}
208
209#[derive(Debug, Clone, Default)]
210enum ChangeScopeKind {
211 #[default]
212 Full,
213 Files(NormalizedChangedFiles),
214 Packages(PackageChangeScope),
215}
216
217impl ChangeScope {
218 pub fn resolve(
230 request: ChangeScopeRequest<'_>,
231 config: &ResolvedConfig,
232 workspaces: &[WorkspaceInfo],
233 ) -> Result<Self, PackageBaselineError> {
234 let global_ref = request.global_ref || request.files.is_some();
235 if let Some(files) = request.files {
236 return Ok(Self::changed_files(files));
237 }
238 if !request.reads_package_baselines(config) {
239 return Ok(Self {
240 global_ref,
241 ..Self::default()
242 });
243 }
244 let resolved = match request.cache {
245 Some(cache) => cache.resolve(config, workspaces)?,
246 None => resolve_packages(config, workspaces)?,
247 };
248 Ok(Self {
249 kind: resolved
250 .packages
251 .map_or(ChangeScopeKind::Full, ChangeScopeKind::Packages),
252 global_ref,
253 outcome: Some(resolved.outcome),
254 })
255 }
256
257 #[must_use]
259 pub fn changed_files(files: &FxHashSet<PathBuf>) -> Self {
260 Self {
261 kind: ChangeScopeKind::Files(NormalizedChangedFiles::new(files)),
262 global_ref: true,
263 outcome: None,
264 }
265 }
266
267 #[must_use]
273 pub const fn scope_reason(&self) -> Option<fallow_output::ScopeReason> {
274 match (&self.kind, self.global_ref) {
275 (ChangeScopeKind::Packages(_), _) => Some(fallow_output::ScopeReason::PackageBaselines),
276 (_, true) => Some(fallow_output::ScopeReason::ChangedSince),
277 (_, false) => None,
278 }
279 }
280
281 #[must_use]
283 pub const fn packages(&self) -> Option<&PackageChangeScope> {
284 match &self.kind {
285 ChangeScopeKind::Packages(packages) => Some(packages),
286 ChangeScopeKind::Full | ChangeScopeKind::Files(_) => None,
287 }
288 }
289
290 #[must_use]
292 pub const fn request_outcome(&self) -> Option<&RequestOutcome> {
293 self.outcome.as_ref()
294 }
295
296 #[must_use]
299 pub fn stand_down_message(&self) -> Option<&str> {
300 self.outcome
301 .as_ref()
302 .filter(|outcome| outcome.status != fallow_output::RequestStatus::Applied)
303 .and_then(|outcome| outcome.message.as_deref())
304 }
305
306 #[must_use]
309 pub fn package_baselines(&self) -> Vec<PackageBaselineStatus> {
310 self.packages().map_or_else(Vec::new, |packages| {
311 package_baseline_statuses(std::slice::from_ref(packages), packages.project_root())
312 })
313 }
314
315 #[must_use]
317 pub fn contains(&self, path: &Path) -> bool {
318 use crate::changed_files::ChangedPathScope as _;
319 match &self.kind {
320 ChangeScopeKind::Full => true,
321 ChangeScopeKind::Files(files) => files.contains(path),
322 ChangeScopeKind::Packages(packages) => packages.contains(path),
323 }
324 }
325
326 pub(crate) fn retain_dead_code(&self, results: &mut AnalysisResults) {
328 match &self.kind {
329 ChangeScopeKind::Full => {}
330 ChangeScopeKind::Files(files) => {
331 crate::changed_files::filter_results_by_path_scope(results, files);
332 }
333 ChangeScopeKind::Packages(packages) => {
334 crate::changed_files::filter_results_by_path_scope(results, packages);
335 }
336 }
337 }
338
339 pub(crate) fn retain_duplication(&self, report: &mut DuplicationReport, root: &Path) {
341 match &self.kind {
342 ChangeScopeKind::Full => {}
343 ChangeScopeKind::Files(files) => {
344 crate::changed_files::filter_duplication_by_path_scope(report, files, root);
345 }
346 ChangeScopeKind::Packages(packages) => {
347 crate::changed_files::filter_duplication_by_path_scope(report, packages, root);
348 }
349 }
350 }
351}
352
353#[must_use]
357pub fn first_package_baselines<'a>(
358 sections: impl IntoIterator<Item = Option<&'a [PackageBaselineStatus]>>,
359) -> Vec<PackageBaselineStatus> {
360 sections
361 .into_iter()
362 .flatten()
363 .find(|rows| !rows.is_empty())
364 .map_or_else(Vec::new, <[PackageBaselineStatus]>::to_vec)
365}
366
367#[must_use]
372pub fn package_baseline_statuses(
373 scopes: &[PackageChangeScope],
374 root: &Path,
375) -> Vec<PackageBaselineStatus> {
376 let root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
377 let mut rows = scopes
378 .iter()
379 .flat_map(|scope| {
380 let prefix = scope
381 .project_root()
382 .strip_prefix(&root)
383 .map(|relative| {
384 relative
385 .components()
386 .map(|component| component.as_os_str().to_string_lossy())
387 .collect::<Vec<_>>()
388 .join("/")
389 })
390 .unwrap_or_default();
391 scope
392 .configured_baselines()
393 .map(move |(key, reference)| PackageBaselineStatus {
394 workspace_root: if prefix.is_empty() {
395 key.to_owned()
396 } else {
397 format!("{prefix}/{key}")
398 },
399 reference: reference.to_owned(),
400 })
401 })
402 .collect::<Vec<_>>();
403 rows.sort_by(|a, b| a.workspace_root.cmp(&b.workspace_root));
404 rows
405}
406
407#[cfg(test)]
408mod tests {
409 use std::collections::BTreeMap;
410
411 use fallow_config::{FallowConfig, OutputFormat};
412
413 use super::*;
414
415 fn config_with_map(root: &Path, map: &[(&str, &str)]) -> ResolvedConfig {
416 let mut config = FallowConfig::default().resolve(
417 root.to_path_buf(),
418 OutputFormat::Json,
419 1,
420 true,
421 true,
422 None,
423 );
424 config.workspace_changed_since = map
425 .iter()
426 .map(|(key, reference)| ((*key).to_owned(), (*reference).to_owned()))
427 .collect::<BTreeMap<_, _>>();
428 config
429 }
430
431 fn unknown_workspace_map(root: &Path) -> ResolvedConfig {
434 config_with_map(root, &[("packages/missing", "HEAD")])
435 }
436
437 #[test]
438 fn caller_owned_scope_never_reads_the_package_map() {
439 let temp = tempfile::tempdir().expect("tempdir");
440 let config = unknown_workspace_map(temp.path());
441 let request = ChangeScopeRequest {
442 owner: ChangeScopeOwner::Caller,
443 ..ChangeScopeRequest::default()
444 };
445 assert!(!request.reads_package_baselines(&config));
446 let scope = ChangeScope::resolve(request, &config, &[]).expect("caller-owned scope");
447 assert!(scope.scope_reason().is_none());
448 assert!(scope.package_baselines().is_empty());
449 assert!(scope.contains(&temp.path().join("packages/a/index.ts")));
450 }
451
452 #[test]
453 fn a_requested_global_ref_suppresses_the_map_even_without_files() {
454 let temp = tempfile::tempdir().expect("tempdir");
455 let config = unknown_workspace_map(temp.path());
456 let request = ChangeScopeRequest {
457 global_ref: true,
458 ..ChangeScopeRequest::default()
459 };
460 let scope = ChangeScope::resolve(request, &config, &[]).expect("global scope");
461 assert!(scope.scope_reason().is_some());
462 assert!(scope.packages().is_none());
463 }
464
465 #[test]
466 fn a_changed_file_set_wins_over_the_map() {
467 let temp = tempfile::tempdir().expect("tempdir");
468 let config = unknown_workspace_map(temp.path());
469 let changed: FxHashSet<PathBuf> = std::iter::once(temp.path().join("a.ts")).collect();
470 let request = ChangeScopeRequest {
471 files: Some(&changed),
472 ..ChangeScopeRequest::default()
473 };
474 let scope = ChangeScope::resolve(request, &config, &[]).expect("file scope");
475 assert!(scope.scope_reason().is_some());
476 assert!(scope.contains(&temp.path().join("a.ts")));
477 assert!(!scope.contains(&temp.path().join("b.ts")));
478 }
479
480 #[test]
483 fn a_key_that_names_no_workspace_stands_the_map_down() {
484 let temp = tempfile::tempdir().expect("tempdir");
485 std::fs::create_dir_all(temp.path().join("packages/web")).expect("package");
486 let config = config_with_map(temp.path(), &[("packages/wbe", "HEAD")]);
487 let workspaces = [WorkspaceInfo {
488 root: temp.path().join("packages/web"),
489 name: "web".to_owned(),
490 is_internal_dependency: false,
491 }];
492 let scope = ChangeScope::resolve(ChangeScopeRequest::default(), &config, &workspaces)
493 .expect("an unknown key stands down");
494 assert!(scope.scope_reason().is_none());
495 assert!(scope.packages().is_none());
496 assert!(scope.package_baselines().is_empty());
497 let outcome = scope.request_outcome().expect("the map was read");
498 assert_eq!(outcome.status, fallow_output::RequestStatus::NotApplied);
499 assert_eq!(outcome.reason.as_deref(), Some("unknown-workspace"));
500 let message = scope.stand_down_message().expect("a stand-down sentence");
501 assert!(
502 message.contains("did you mean 'packages/web'?"),
503 "{message}"
504 );
505 }
506
507 #[test]
508 fn a_malformed_key_or_ref_is_invalid_input() {
509 let temp = tempfile::tempdir().expect("tempdir");
510 for map in [
511 [("./packages/web", "HEAD")],
512 [("packages/web", "-malformed")],
513 ] {
514 std::fs::create_dir_all(temp.path().join("packages/web")).expect("package");
515 let config = config_with_map(temp.path(), &map);
516 let workspaces = [WorkspaceInfo {
517 root: temp.path().join("packages/web"),
518 name: "web".to_owned(),
519 is_internal_dependency: false,
520 }];
521 assert!(
522 ChangeScope::resolve(ChangeScopeRequest::default(), &config, &workspaces).is_err(),
523 "{map:?}"
524 );
525 }
526 }
527
528 #[test]
529 fn the_run_memo_resolves_the_map_once() {
530 let temp = tempfile::tempdir().expect("tempdir");
531 let config = unknown_workspace_map(temp.path());
532 let cache = PackageBaselineCache::new();
533 assert!(!cache.is_resolved());
534 assert!(cache.request_outcome().is_none());
535 let request = ChangeScopeRequest {
536 cache: Some(&cache),
537 ..ChangeScopeRequest::default()
538 };
539 ChangeScope::resolve(request, &config, &[]).expect("first resolution");
540 assert!(cache.is_resolved());
541 let later_config = config_with_map(temp.path(), &[]);
542 let later = ChangeScope::resolve(
543 ChangeScopeRequest {
544 cache: Some(&cache),
545 ..ChangeScopeRequest::default()
546 },
547 &config,
548 &[],
549 )
550 .expect("memo hit");
551 assert_eq!(later.request_outcome(), cache.request_outcome().as_ref());
552 let empty = ChangeScope::resolve(ChangeScopeRequest::default(), &later_config, &[])
553 .expect("no map, full scope");
554 assert!(empty.scope_reason().is_none());
555 assert!(empty.request_outcome().is_none());
556 }
557}