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.
46    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    /// Recursive execution includes every Eval in the required Artifact scope.
114    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
185/// Resolve complete declared profiles in owned configuration, never rewriting source files.
186pub 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    // Runtime variants can introduce or remove argv references and dependency gates.
237    crate::scope::resolve_config(&mut config).map_err(|error| error.to_string())?;
238    Ok(config)
239}
240
241/// Bound selection-file parsing/allocation before resolving any project work.
242const MAX_FILE_BYTES: usize = 4 * 1024 * 1024;
243/// Bound resolution and deduplication work even for files containing many short IDs.
244const 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
248// Match ECMAScript trim, including BOM but excluding NEXT LINE.
249fn 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            // A valid array containing a non-string is a format error, not broken JSON.
267            // Validate only syntax after a data error, preserving malformed-array precedence.
268            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    // Nonblocking open lets us reject a FIFO without waiting for its writer.
303    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;