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> {
48 let evals_by_id: BTreeMap<_, _> = config
49 .evals
50 .iter()
51 .map(|eval| (eval.id.as_str(), eval))
52 .collect();
53 let mut evals_by_target: BTreeMap<&ArtifactName, Vec<&Eval>> = BTreeMap::new();
54 for eval in &config.evals {
55 evals_by_target.entry(&eval.target).or_default().push(eval);
56 }
57 let mut result = ResolvedSelection {
58 evals: Vec::new(),
59 roots: Vec::new(),
60 };
61 match self {
62 Self::All => {
63 result.evals.extend(&config.evals);
64 result.roots.extend(config.artifacts.keys());
65 }
66 Self::Artifact { artifact_id } => {
67 result.roots.push(selected_artifact(config, artifact_id)?);
68 }
69 Self::Artifacts { artifact_ids } => {
70 nonempty(artifact_ids)?;
71 for id in artifact_ids {
72 result.roots.push(selected_artifact(config, id)?);
73 }
74 }
75 Self::Eval { eval_id } => {
76 result.evals.push(
77 *evals_by_id
78 .get(eval_id.as_str())
79 .ok_or_else(|| format!("Unknown Eval: {eval_id}"))?,
80 );
81 }
82 Self::Evals { eval_ids } => {
83 nonempty(eval_ids)?;
84 for id in eval_ids {
85 result.evals.push(
86 *evals_by_id
87 .get(id.as_str())
88 .ok_or_else(|| format!("Unknown Eval: {id}"))?,
89 );
90 }
91 }
92 }
93 if matches!(self, Self::Eval { .. } | Self::Evals { .. }) {
94 result
95 .roots
96 .extend(result.evals.iter().map(|eval| &eval.target));
97 }
98 if matches!(self, Self::Artifact { .. } | Self::Artifacts { .. }) {
100 let parts: Vec<_> = result
101 .roots
102 .iter()
103 .flat_map(|&id| config.descendants(id))
104 .collect();
105 result.roots.extend(parts);
106 }
107 let mut seen = BTreeSet::new();
108 result.roots.retain(|id| seen.insert(*id));
109 if matches!(self, Self::Artifact { .. } | Self::Artifacts { .. }) {
110 for id in &result.roots {
111 if let Some(evals) = evals_by_target.get(id) {
112 result.evals.extend(evals);
113 }
114 }
115 }
116 let mut seen_evals = BTreeSet::new();
117 result
118 .evals
119 .retain(|eval| seen_evals.insert(eval.id.as_str()));
120 Ok(result)
121 }
122
123 pub fn included_evals<'a>(
127 &self,
128 config: &'a RepoConfig,
129 recursive: bool,
130 ) -> Result<Vec<&'a Eval>, String> {
131 let selected = self.resolve(config)?;
132 let peers = matches!(self, Self::Artifact { .. } | Self::Artifacts { .. });
133 if !recursive && !peers {
134 return Ok(selected.evals);
135 }
136 let graph = Graph::new(config).map_err(|error| error.to_string())?;
137 let required: BTreeSet<_> = if recursive {
138 graph.dependency_closure(&selected.roots)
139 } else {
140 graph.cycle_members(&selected.roots)
141 }
142 .map_err(|error| error.to_string())?
143 .into_iter()
144 .collect();
145 let included = config
146 .evals
147 .iter()
148 .filter(|eval| required.contains(&eval.target));
149 if recursive {
150 return Ok(included.collect());
151 }
152 let selected_ids: BTreeSet<_> = selected.evals.iter().map(|&eval| &eval.id).collect();
153 let peer_evals: Vec<_> = included
154 .filter(|eval| !selected_ids.contains(&eval.id))
155 .collect();
156 Ok(selected.evals.into_iter().chain(peer_evals).collect())
157 }
158}
159
160fn nonempty<T>(ids: &[T]) -> Result<(), String> {
161 if ids.is_empty() {
162 return Err("Multi-root selection requires a nonempty array of IDs.".into());
163 }
164 Ok(())
165}
166
167fn selected_artifact<'a>(config: &'a RepoConfig, id: &str) -> Result<&'a ArtifactName, String> {
168 config
169 .artifacts
170 .get_key_value(id)
171 .map(|(id, _)| id)
172 .ok_or_else(|| format!("Unknown Artifact: {id}"))
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
176#[serde(untagged)]
177pub enum ProfileSelection {
178 Named(ProfileVariantName),
179 Evals(BTreeMap<EvalId, ProfileVariantName>),
180}
181
182pub fn select_profiles(
184 mut config: RepoConfig,
185 selection: &Selection,
186 profile: Option<&ProfileSelection>,
187 recursive: bool,
188) -> Result<RepoConfig, String> {
189 let Some(profile) = profile else {
190 return Ok(config);
191 };
192 let evals = selection.included_evals(&config, recursive)?;
193 let included: BTreeMap<_, _> = evals.iter().map(|eval| (&eval.id, *eval)).collect();
194 let mapping: Vec<(&EvalId, &ProfileVariantName)> = match profile {
195 ProfileSelection::Named(name) => evals.iter().map(|eval| (&eval.id, name)).collect(),
196 ProfileSelection::Evals(mapping) => mapping.iter().collect(),
197 };
198 let mut profiles = BTreeMap::new();
199 for (id, name) in mapping {
200 let eval = included.get(id).ok_or_else(|| {
201 format!("Profile selection is outside the submitted Eval scope: {id}")
202 })?;
203 let variant = eval
204 .declaration
205 .profile_variants()
206 .get(name)
207 .ok_or_else(|| format!("Unknown profile variant for {id}: {name}"))?;
208 profiles.insert(id.clone(), (name.clone(), variant.clone()));
209 }
210 for eval in &mut config.evals {
211 if let Some((name, _profile)) = profiles.remove(&eval.id) {
212 eval.declaration.select_profile(&name)?;
213 eval.variant = Some(name);
214 }
215 }
216 crate::scope::resolve_config(&mut config).map_err(|error| error.to_string())?;
218 Ok(config)
219}
220
221const MAX_FILE_BYTES: usize = 4 * 1024 * 1024;
223const MAX_IDS: usize = 100_000;
225const FILE_FORMAT_ERROR: &str =
226 "Selection file must contain 1–100000 nonempty IDs, as a JSON string array or one ID per line.";
227
228fn trim(text: &str) -> &str {
230 text.trim_matches(|ch| {
231 matches!(ch,
232 '\u{0009}'..='\u{000d}' | '\u{0020}' | '\u{00a0}' | '\u{1680}' |
233 '\u{2000}'..='\u{200a}' | '\u{2028}' | '\u{2029}' | '\u{202f}' |
234 '\u{205f}' | '\u{3000}' | '\u{feff}'
235 )
236 })
237}
238
239pub fn parse_selection_file(text: &str) -> Result<Vec<String>, String> {
240 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
241 if text.len() > MAX_FILE_BYTES {
242 return Err("Selection file exceeds 4 MiB.".into());
243 }
244 let ids: Vec<String> = if trim(text).starts_with('[') {
245 serde_json::from_str::<Vec<String>>(text).map_err(|error| {
246 if error.is_data() && serde_json::from_str::<crate::json::Ignored>(text).is_ok() {
249 FILE_FORMAT_ERROR.to_owned()
250 } else {
251 "Selection file contains invalid JSON.".to_owned()
252 }
253 })?
254 } else {
255 text.split('\n')
256 .map(trim)
257 .filter(|line| !line.is_empty())
258 .take(MAX_IDS + 1)
259 .map(str::to_owned)
260 .collect()
261 };
262 if ids.is_empty()
263 || ids.len() > MAX_IDS
264 || ids.iter().any(|id| {
265 id.is_empty()
266 || trim(id) != id
267 || id
268 .bytes()
269 .any(|byte| byte <= b' ' || byte == 127 || byte == b',')
270 })
271 {
272 return Err(FILE_FORMAT_ERROR.into());
273 }
274 let mut seen = BTreeSet::new();
275 Ok(ids
276 .into_iter()
277 .filter(|id| seen.insert(id.clone()))
278 .collect())
279}
280
281pub fn read_selection_file(path: &Path) -> Result<Vec<String>, String> {
282 let file = crate::platform::open_nonblocking(path)
284 .map_err(|error| format!("{}: {error}", crate::platform::path_text(path)))?;
285 let info = file.metadata().map_err(|error| error.to_string())?;
286 if !info.is_file() || info.len() > MAX_FILE_BYTES as u64 {
287 return Err("Selection input must be a regular file no larger than 4 MiB.".into());
288 }
289 let mut bytes = Vec::new();
290 file.take(MAX_FILE_BYTES as u64 + 1)
291 .read_to_end(&mut bytes)
292 .map_err(|error| error.to_string())?;
293 if bytes.len() > MAX_FILE_BYTES {
294 return Err("Selection file exceeds 4 MiB.".into());
295 }
296 parse_selection_file(&String::from_utf8_lossy(&bytes))
297}
298
299#[cfg(test)]
300mod tests;