Skip to main content

artifactize_core/project/
selection.rs

1//! Project selectors, selection files, and profile variants.
2
3use 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    /// Preserve selector order, then declaration order, keeping each root/Eval once. An
46    /// Artifact selector's roots are the Artifact and its parts.
47    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        // An Artifact is made of its parts, so selecting it selects them too.
99        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    /// Recursive execution includes every Eval in the required Artifact scope. An Artifact
124    /// selection also includes its cycle peers' Evals, after the selected ones: they review
125    /// the selected Artifact, so only this Run can give them current results.
126    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
182/// Resolve complete declared profiles in owned configuration, never rewriting source files.
183pub 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    // Runtime variants can introduce or remove argv references and dependency gates.
217    crate::scope::resolve_config(&mut config).map_err(|error| error.to_string())?;
218    Ok(config)
219}
220
221/// Bound selection-file parsing/allocation before resolving any project work.
222const MAX_FILE_BYTES: usize = 4 * 1024 * 1024;
223/// Bound resolution and deduplication work even for files containing many short IDs.
224const 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
228// Match ECMAScript trim, including BOM but excluding NEXT LINE.
229fn 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            // A valid array containing a non-string is a format error, not broken JSON.
247            // Validate only syntax after a data error, preserving malformed-array precedence.
248            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    // Nonblocking open lets us reject a FIFO without waiting for its writer.
283    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;