git_policy_cli/dependent_version_bundled.rs
1//! What: Decide which development dependencies each dependent bundles, reading source
2//! only where an edge could carry a raised version.
3//! Why: A development dependency ships inside a dependent only when its non-test source
4//! imports it. Scanning every package's source on every commit would read the whole
5//! workspace, so, as in the incumbent (`dependent-bump-workflow.ts:176-278`), only
6//! dependents that could reach a raised package if every development dependency were
7//! bundled are scanned, and only for edges that lead toward a raised package.
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! // await bundledDevelopmentEdges({ reader, states, bumpedNames });
12//! ```
13
14/// What: `use` brings names from sibling files and the standard library into this file.
15/// Why: The superset walk is the plan's own walk; source paths and imports have their
16/// own modules.
17///
18/// In TS you'd write (pseudocode):
19/// ```ts
20/// import { transitiveDependentNames } from './dependent_version_graph.ts';
21/// ```
22use super::dependent_version_content::{ContentUnavailable, TrackedPath, WorkspaceContent};
23/// The superset walk over every edge.
24use super::dependent_version_graph::{WorkspaceNode, transitive_dependent_names};
25/// The specifier scan.
26use super::dependent_version_imports::imports_package;
27/// Which tracked paths are a package's non-test source.
28use super::dependent_version_paths::is_non_test_source_path;
29/// One manifest's facts and directory.
30use super::dependent_version_states::ManifestState;
31/// A hash-based set of relevant names.
32use std::collections::HashSet;
33
34/// What: The development dependencies of one dependent that could carry a raised version.
35/// Why: An edge to a package outside the superset and the raised set leads nowhere; a self
36/// edge is never an edge.
37///
38/// In TS you'd write (pseudocode):
39/// ```ts
40/// state.current.devDependencyNames.filter(name => relevant.has(name) && name !== state.current.name);
41/// ```
42fn candidate_edges(state: &ManifestState, relevant: &HashSet<String>) -> Vec<String> {
43 return state
44 .facts
45 .dev_dependency_names
46 .iter()
47 .filter(|name: &&String| return relevant.contains(*name) && **name != state.facts.name)
48 .cloned()
49 .collect();
50}
51
52/// What: The texts of a package's non-test source files. `String::from_utf8_lossy`
53/// replaces bytes that are not UTF-8 with U+FFFD, as the incumbent's lenient decoder
54/// does.
55/// Why: A source file only needs scanning for specifiers, so undecodable bytes are not a
56/// failure.
57///
58/// In TS you'd write (pseudocode):
59/// ```ts
60/// await Promise.all((await reader.sourceFiles(directory)).filter(isSource).map(file => file.text()));
61/// ```
62fn source_texts(
63 content: &mut dyn WorkspaceContent,
64 tracked: &[TrackedPath],
65 directory: &[u8],
66) -> Result<Vec<String>, ContentUnavailable> {
67 let mut texts: Vec<String> = Vec::new();
68 for entry in tracked {
69 if is_non_test_source_path(directory, &entry.path) {
70 let bytes: Vec<u8> = content.candidate_bytes(&entry.path)?;
71 texts.push(String::from_utf8_lossy(&bytes).into_owned());
72 }
73 }
74 return Ok(texts);
75}
76
77/// What: For each state, in order, the development dependencies its source imports among
78/// those that could carry a raised version. `Vec<Vec<String>>` is one list per state.
79/// Why: These become edges of the plan's walk alongside the runtime edges.
80///
81/// In TS you'd write (pseudocode):
82/// ```ts
83/// async function bundledDevelopmentEdges({ reader, states, bumpedNames }): Map<string, string[]>;
84/// ```
85pub fn bundled_development_edges(
86 content: &mut dyn WorkspaceContent,
87 tracked: &[TrackedPath],
88 states: &[ManifestState],
89 bumped_names: &[String],
90) -> Result<Vec<Vec<String>>, ContentUnavailable> {
91 let every_edge: Vec<WorkspaceNode> = states
92 .iter()
93 .map(|state: &ManifestState| {
94 return WorkspaceNode {
95 name: state.facts.name.clone(),
96 directory: state.directory.clone(),
97 version: None,
98 edge_names: [
99 state.facts.runtime_dependency_names.as_slice(),
100 state.facts.dev_dependency_names.as_slice(),
101 ]
102 .concat(),
103 };
104 })
105 .collect();
106 let mut relevant: HashSet<String> = transitive_dependent_names(&every_edge, bumped_names);
107 relevant.extend(bumped_names.iter().cloned());
108 let mut bundled: Vec<Vec<String>> = Vec::new();
109 for state in states {
110 // The incumbent also skips a dependent outside the superset. That test is implied:
111 // an edge to a relevant workspace package puts the dependent in the superset.
112 let candidates: Vec<String> = candidate_edges(state, &relevant);
113 if candidates.is_empty() {
114 bundled.push(Vec::new());
115 continue;
116 }
117 let texts: Vec<String> = source_texts(content, tracked, &state.directory)?;
118 bundled.push(
119 candidates
120 .into_iter()
121 .filter(|name: &String| {
122 return texts
123 .iter()
124 .any(|text: &String| return imports_package(text, name));
125 })
126 .collect(),
127 );
128 }
129 return Ok(bundled);
130}
131
132/// Which dependents are scanned, which edges are confirmed, and which files are read.
133#[cfg(test)]
134#[path = "dependent_version_bundled_tests.rs"]
135mod tests;