artifactize_core/project/
selection.rs1use std::{
4 collections::{BTreeMap, BTreeSet},
5 io::Read,
6 path::Path,
7};
8
9use serde::{Deserialize, Serialize};
10
11use crate::{
12 config::{ArtifactName, Eval, EvalId, ProfileVariantName, RepoConfig},
13 graph::Graph,
14};
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)]
18pub enum Selection {
19 All,
20 Artifact {
21 #[serde(rename = "artifactId")]
22 artifact_id: ArtifactName,
23 },
24 Eval {
25 #[serde(rename = "evalId")]
26 eval_id: EvalId,
27 },
28 Artifacts {
29 #[serde(rename = "artifactIds")]
30 artifact_ids: Vec<ArtifactName>,
31 },
32 Evals {
33 #[serde(rename = "evalIds")]
34 eval_ids: Vec<EvalId>,
35 },
36}
37
38#[derive(Debug)]
39pub struct ResolvedSelection<'a> {
40 pub evals: Vec<&'a Eval>,
41 pub roots: Vec<&'a ArtifactName>,
42}
43
44impl Selection {
45 pub fn resolve<'a>(&self, config: &'a RepoConfig) -> Result<ResolvedSelection<'a>, String> {
47 let evals_by_id: BTreeMap<_, _> = config
48 .evals
49 .iter()
50 .map(|eval| (eval.id.as_str(), eval))
51 .collect();
52 let mut evals_by_target: BTreeMap<&ArtifactName, Vec<&Eval>> = BTreeMap::new();
53 for eval in &config.evals {
54 evals_by_target.entry(&eval.target).or_default().push(eval);
55 }
56 let mut result = ResolvedSelection {
57 evals: Vec::new(),
58 roots: Vec::new(),
59 };
60 match self {
61 Self::All => {
62 result.evals.extend(&config.evals);
63 result.roots.extend(config.artifacts.keys());
64 }
65 Self::Artifact { artifact_id } => {
66 result.roots.push(selected_artifact(config, artifact_id)?);
67 }
68 Self::Artifacts { artifact_ids } => {
69 nonempty(artifact_ids)?;
70 for id in artifact_ids {
71 result.roots.push(selected_artifact(config, id)?);
72 }
73 }
74 Self::Eval { eval_id } => {
75 result.evals.push(
76 *evals_by_id
77 .get(eval_id.as_str())
78 .ok_or_else(|| format!("Unknown Eval: {eval_id}"))?,
79 );
80 }
81 Self::Evals { eval_ids } => {
82 nonempty(eval_ids)?;
83 for id in eval_ids {
84 result.evals.push(
85 *evals_by_id
86 .get(id.as_str())
87 .ok_or_else(|| format!("Unknown Eval: {id}"))?,
88 );
89 }
90 }
91 }
92 if matches!(self, Self::Eval { .. } | Self::Evals { .. }) {
93 result
94 .roots
95 .extend(result.evals.iter().map(|eval| &eval.target));
96 }
97 let mut seen = BTreeSet::new();
98 result.roots.retain(|id| seen.insert(*id));
99 if matches!(self, Self::Artifact { .. } | Self::Artifacts { .. }) {
100 for id in &result.roots {
101 if let Some(evals) = evals_by_target.get(id) {
102 result.evals.extend(evals);
103 }
104 }
105 }
106 let mut seen_evals = BTreeSet::new();
107 result
108 .evals
109 .retain(|eval| seen_evals.insert(eval.id.as_str()));
110 Ok(result)
111 }
112
113 pub fn included_evals<'a>(
115 &self,
116 config: &'a RepoConfig,
117 recursive: bool,
118 ) -> Result<Vec<&'a Eval>, String> {
119 let selected = self.resolve(config)?;
120 if !recursive {
121 let graph = Graph::new(config).map_err(|error| error.to_string())?;
122 let mut included = selected.evals;
123 let mut seen: BTreeSet<_> = included.iter().map(|eval| eval.id.as_str()).collect();
124 let mut index = 0;
125 while index < included.len() {
126 let eval = included[index];
127 if matches!(
128 eval.declaration.profile(),
129 crate::config::Profile::Dependency { .. }
130 ) {
131 let roots: Vec<_> = eval.deps.iter().collect();
132 let required: BTreeSet<_> = graph
133 .dependency_closure(&roots)
134 .map_err(|error| error.to_string())?
135 .into_iter()
136 .collect();
137 for dependency in &config.evals {
138 if required.contains(&dependency.target)
139 && seen.insert(dependency.id.as_str())
140 {
141 included.push(dependency);
142 }
143 }
144 }
145 index += 1;
146 }
147 return Ok(included);
148 }
149 let graph = Graph::new(config).map_err(|error| error.to_string())?;
150 let required: BTreeSet<_> = graph
151 .dependency_closure(&selected.roots)
152 .map_err(|error| error.to_string())?
153 .into_iter()
154 .collect();
155 Ok(config
156 .evals
157 .iter()
158 .filter(|eval| required.contains(&eval.target))
159 .collect())
160 }
161}
162
163fn nonempty<T>(ids: &[T]) -> Result<(), String> {
164 if ids.is_empty() {
165 return Err("Multi-root selection requires a nonempty array of IDs.".into());
166 }
167 Ok(())
168}
169
170fn selected_artifact<'a>(config: &'a RepoConfig, id: &str) -> Result<&'a ArtifactName, String> {
171 config
172 .artifacts
173 .get_key_value(id)
174 .map(|(id, _)| id)
175 .ok_or_else(|| format!("Unknown Artifact: {id}"))
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
179#[serde(untagged)]
180pub enum ProfileSelection {
181 Named(ProfileVariantName),
182 Evals(BTreeMap<EvalId, ProfileVariantName>),
183}
184
185pub fn select_profiles(
187 mut config: RepoConfig,
188 selection: &Selection,
189 profile: Option<&ProfileSelection>,
190 recursive: bool,
191) -> Result<RepoConfig, String> {
192 let Some(profile) = profile else {
193 return Ok(config);
194 };
195 let evals = selection.included_evals(&config, recursive)?;
196 let included: BTreeMap<_, _> = evals.iter().map(|eval| (&eval.id, *eval)).collect();
197 let mapping: Vec<(&EvalId, &ProfileVariantName)> = match profile {
198 ProfileSelection::Named(name) => evals
199 .iter()
200 .filter(|eval| {
201 !matches!(
202 eval.declaration.profile(),
203 crate::config::Profile::Dependency { .. }
204 )
205 })
206 .map(|eval| (&eval.id, name))
207 .collect(),
208 ProfileSelection::Evals(mapping) => mapping.iter().collect(),
209 };
210 let mut profiles = BTreeMap::new();
211 for (id, name) in mapping {
212 let eval = included.get(id).ok_or_else(|| {
213 format!("Profile selection is outside the submitted Eval scope: {id}")
214 })?;
215 if matches!(
216 eval.declaration.profile(),
217 crate::config::Profile::Dependency { .. }
218 ) {
219 return Err(format!(
220 "Dependency Eval {id} cannot select a profile variant."
221 ));
222 }
223 let variant = eval
224 .declaration
225 .profile_variants()
226 .get(name)
227 .ok_or_else(|| format!("Unknown profile variant for {id}: {name}"))?;
228 profiles.insert(id.clone(), (name.clone(), variant.clone()));
229 }
230 for eval in &mut config.evals {
231 if let Some((name, _profile)) = profiles.remove(&eval.id) {
232 eval.declaration.select_profile(&name)?;
233 eval.variant = Some(name);
234 }
235 }
236 crate::scope::resolve_config(&mut config).map_err(|error| error.to_string())?;
238 Ok(config)
239}
240
241const MAX_FILE_BYTES: usize = 4 * 1024 * 1024;
243const MAX_IDS: usize = 100_000;
245const FILE_FORMAT_ERROR: &str =
246 "Selection file must contain 1–100000 nonempty IDs, as a JSON string array or one ID per line.";
247
248fn trim(text: &str) -> &str {
250 text.trim_matches(|ch| {
251 matches!(ch,
252 '\u{0009}'..='\u{000d}' | '\u{0020}' | '\u{00a0}' | '\u{1680}' |
253 '\u{2000}'..='\u{200a}' | '\u{2028}' | '\u{2029}' | '\u{202f}' |
254 '\u{205f}' | '\u{3000}' | '\u{feff}'
255 )
256 })
257}
258
259pub fn parse_selection_file(text: &str) -> Result<Vec<String>, String> {
260 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
261 if text.len() > MAX_FILE_BYTES {
262 return Err("Selection file exceeds 4 MiB.".into());
263 }
264 let ids: Vec<String> = if trim(text).starts_with('[') {
265 serde_json::from_str::<Vec<String>>(text).map_err(|error| {
266 if error.is_data() && serde_json::from_str::<crate::json::Ignored>(text).is_ok() {
269 FILE_FORMAT_ERROR.to_owned()
270 } else {
271 "Selection file contains invalid JSON.".to_owned()
272 }
273 })?
274 } else {
275 text.split('\n')
276 .map(trim)
277 .filter(|line| !line.is_empty())
278 .take(MAX_IDS + 1)
279 .map(str::to_owned)
280 .collect()
281 };
282 if ids.is_empty()
283 || ids.len() > MAX_IDS
284 || ids.iter().any(|id| {
285 id.is_empty()
286 || trim(id) != id
287 || id
288 .bytes()
289 .any(|byte| byte <= b' ' || byte == 127 || byte == b',')
290 })
291 {
292 return Err(FILE_FORMAT_ERROR.into());
293 }
294 let mut seen = BTreeSet::new();
295 Ok(ids
296 .into_iter()
297 .filter(|id| seen.insert(id.clone()))
298 .collect())
299}
300
301pub fn read_selection_file(path: &Path) -> Result<Vec<String>, String> {
302 let file = crate::platform::open_nonblocking(path)
304 .map_err(|error| format!("{}: {error}", crate::platform::path_text(path)))?;
305 let info = file.metadata().map_err(|error| error.to_string())?;
306 if !info.is_file() || info.len() > MAX_FILE_BYTES as u64 {
307 return Err("Selection input must be a regular file no larger than 4 MiB.".into());
308 }
309 let mut bytes = Vec::new();
310 file.take(MAX_FILE_BYTES as u64 + 1)
311 .read_to_end(&mut bytes)
312 .map_err(|error| error.to_string())?;
313 if bytes.len() > MAX_FILE_BYTES {
314 return Err("Selection file exceeds 4 MiB.".into());
315 }
316 parse_selection_file(&String::from_utf8_lossy(&bytes))
317}
318
319#[cfg(test)]
320mod tests;