artifactize-core 0.13.0

The library behind artifactize: Artifacts, evals, reviews, the monitor and review models, and the shared operations every interface calls.
Documentation
//! Project selectors, selection files, and profile variants.

use std::{
    collections::{BTreeMap, BTreeSet},
    io::Read,
    path::Path,
};

use serde::{Deserialize, Serialize};

use crate::{
    config::{ArtifactName, Eval, EvalId, ProfileVariantName, RepoConfig},
    graph::Graph,
};

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)]
pub enum Selection {
    All,
    Artifact {
        #[serde(rename = "artifactId")]
        artifact_id: ArtifactName,
    },
    Eval {
        #[serde(rename = "evalId")]
        eval_id: EvalId,
    },
    Artifacts {
        #[serde(rename = "artifactIds")]
        artifact_ids: Vec<ArtifactName>,
    },
    Evals {
        #[serde(rename = "evalIds")]
        eval_ids: Vec<EvalId>,
    },
}

#[derive(Debug)]
pub struct ResolvedSelection<'a> {
    pub evals: Vec<&'a Eval>,
    pub roots: Vec<&'a ArtifactName>,
}

impl Selection {
    /// Preserve selector order, then declaration order, keeping each root/Eval once.
    pub fn resolve<'a>(&self, config: &'a RepoConfig) -> Result<ResolvedSelection<'a>, String> {
        let evals_by_id: BTreeMap<_, _> = config
            .evals
            .iter()
            .map(|eval| (eval.id.as_str(), eval))
            .collect();
        let mut evals_by_target: BTreeMap<&ArtifactName, Vec<&Eval>> = BTreeMap::new();
        for eval in &config.evals {
            evals_by_target.entry(&eval.target).or_default().push(eval);
        }
        let mut result = ResolvedSelection {
            evals: Vec::new(),
            roots: Vec::new(),
        };
        match self {
            Self::All => {
                result.evals.extend(&config.evals);
                result.roots.extend(config.artifacts.keys());
            }
            Self::Artifact { artifact_id } => {
                result.roots.push(selected_artifact(config, artifact_id)?);
            }
            Self::Artifacts { artifact_ids } => {
                nonempty(artifact_ids)?;
                for id in artifact_ids {
                    result.roots.push(selected_artifact(config, id)?);
                }
            }
            Self::Eval { eval_id } => {
                result.evals.push(
                    *evals_by_id
                        .get(eval_id.as_str())
                        .ok_or_else(|| format!("Unknown Eval: {eval_id}"))?,
                );
            }
            Self::Evals { eval_ids } => {
                nonempty(eval_ids)?;
                for id in eval_ids {
                    result.evals.push(
                        *evals_by_id
                            .get(id.as_str())
                            .ok_or_else(|| format!("Unknown Eval: {id}"))?,
                    );
                }
            }
        }
        if matches!(self, Self::Eval { .. } | Self::Evals { .. }) {
            result
                .roots
                .extend(result.evals.iter().map(|eval| &eval.target));
        }
        let mut seen = BTreeSet::new();
        result.roots.retain(|id| seen.insert(*id));
        if matches!(self, Self::Artifact { .. } | Self::Artifacts { .. }) {
            for id in &result.roots {
                if let Some(evals) = evals_by_target.get(id) {
                    result.evals.extend(evals);
                }
            }
        }
        let mut seen_evals = BTreeSet::new();
        result
            .evals
            .retain(|eval| seen_evals.insert(eval.id.as_str()));
        Ok(result)
    }

    /// Recursive execution includes every Eval in the required Artifact scope.
    pub fn included_evals<'a>(
        &self,
        config: &'a RepoConfig,
        recursive: bool,
    ) -> Result<Vec<&'a Eval>, String> {
        let selected = self.resolve(config)?;
        if !recursive {
            return Ok(selected.evals);
        }
        let graph = Graph::new(config).map_err(|error| error.to_string())?;
        let required: BTreeSet<_> = graph
            .dependency_closure(&selected.roots)
            .map_err(|error| error.to_string())?
            .into_iter()
            .collect();
        Ok(config
            .evals
            .iter()
            .filter(|eval| required.contains(&eval.target))
            .collect())
    }
}

fn nonempty<T>(ids: &[T]) -> Result<(), String> {
    if ids.is_empty() {
        return Err("Multi-root selection requires a nonempty array of IDs.".into());
    }
    Ok(())
}

fn selected_artifact<'a>(config: &'a RepoConfig, id: &str) -> Result<&'a ArtifactName, String> {
    config
        .artifacts
        .get_key_value(id)
        .map(|(id, _)| id)
        .ok_or_else(|| format!("Unknown Artifact: {id}"))
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ProfileSelection {
    Named(ProfileVariantName),
    Evals(BTreeMap<EvalId, ProfileVariantName>),
}

/// Resolve complete declared profiles in owned configuration, never rewriting source files.
pub fn select_profiles(
    mut config: RepoConfig,
    selection: &Selection,
    profile: Option<&ProfileSelection>,
    recursive: bool,
) -> Result<RepoConfig, String> {
    let Some(profile) = profile else {
        return Ok(config);
    };
    let evals = selection.included_evals(&config, recursive)?;
    let included: BTreeMap<_, _> = evals.iter().map(|eval| (&eval.id, *eval)).collect();
    let mapping: Vec<(&EvalId, &ProfileVariantName)> = match profile {
        ProfileSelection::Named(name) => evals.iter().map(|eval| (&eval.id, name)).collect(),
        ProfileSelection::Evals(mapping) => mapping.iter().collect(),
    };
    let mut profiles = BTreeMap::new();
    for (id, name) in mapping {
        let eval = included.get(id).ok_or_else(|| {
            format!("Profile selection is outside the submitted Eval scope: {id}")
        })?;
        let variant = eval
            .declaration
            .profile_variants()
            .get(name)
            .ok_or_else(|| format!("Unknown profile variant for {id}: {name}"))?;
        profiles.insert(id.clone(), (name.clone(), variant.clone()));
    }
    for eval in &mut config.evals {
        if let Some((name, _profile)) = profiles.remove(&eval.id) {
            eval.declaration.select_profile(&name)?;
            eval.variant = Some(name);
        }
    }
    // Runtime variants can introduce or remove argv references and dependency gates.
    crate::scope::resolve_config(&mut config).map_err(|error| error.to_string())?;
    Ok(config)
}

/// Bound selection-file parsing/allocation before resolving any project work.
const MAX_FILE_BYTES: usize = 4 * 1024 * 1024;
/// Bound resolution and deduplication work even for files containing many short IDs.
const MAX_IDS: usize = 100_000;
const FILE_FORMAT_ERROR: &str =
    "Selection file must contain 1–100000 nonempty IDs, as a JSON string array or one ID per line.";

// Match ECMAScript trim, including BOM but excluding NEXT LINE.
fn trim(text: &str) -> &str {
    text.trim_matches(|ch| {
        matches!(ch,
            '\u{0009}'..='\u{000d}' | '\u{0020}' | '\u{00a0}' | '\u{1680}' |
            '\u{2000}'..='\u{200a}' | '\u{2028}' | '\u{2029}' | '\u{202f}' |
            '\u{205f}' | '\u{3000}' | '\u{feff}'
        )
    })
}

pub fn parse_selection_file(text: &str) -> Result<Vec<String>, String> {
    let text = text.strip_prefix('\u{feff}').unwrap_or(text);
    if text.len() > MAX_FILE_BYTES {
        return Err("Selection file exceeds 4 MiB.".into());
    }
    let ids: Vec<String> = if trim(text).starts_with('[') {
        serde_json::from_str::<Vec<String>>(text).map_err(|error| {
            // A valid array containing a non-string is a format error, not broken JSON.
            // Validate only syntax after a data error, preserving malformed-array precedence.
            if error.is_data() && serde_json::from_str::<crate::json::Ignored>(text).is_ok() {
                FILE_FORMAT_ERROR.to_owned()
            } else {
                "Selection file contains invalid JSON.".to_owned()
            }
        })?
    } else {
        text.split('\n')
            .map(trim)
            .filter(|line| !line.is_empty())
            .take(MAX_IDS + 1)
            .map(str::to_owned)
            .collect()
    };
    if ids.is_empty()
        || ids.len() > MAX_IDS
        || ids.iter().any(|id| {
            id.is_empty()
                || trim(id) != id
                || id
                    .bytes()
                    .any(|byte| byte <= b' ' || byte == 127 || byte == b',')
        })
    {
        return Err(FILE_FORMAT_ERROR.into());
    }
    let mut seen = BTreeSet::new();
    Ok(ids
        .into_iter()
        .filter(|id| seen.insert(id.clone()))
        .collect())
}

pub fn read_selection_file(path: &Path) -> Result<Vec<String>, String> {
    // Nonblocking open lets us reject a FIFO without waiting for its writer.
    let file = crate::platform::open_nonblocking(path)
        .map_err(|error| format!("{}: {error}", crate::platform::path_text(path)))?;
    let info = file.metadata().map_err(|error| error.to_string())?;
    if !info.is_file() || info.len() > MAX_FILE_BYTES as u64 {
        return Err("Selection input must be a regular file no larger than 4 MiB.".into());
    }
    let mut bytes = Vec::new();
    file.take(MAX_FILE_BYTES as u64 + 1)
        .read_to_end(&mut bytes)
        .map_err(|error| error.to_string())?;
    if bytes.len() > MAX_FILE_BYTES {
        return Err("Selection file exceeds 4 MiB.".into());
    }
    parse_selection_file(&String::from_utf8_lossy(&bytes))
}

#[cfg(test)]
mod tests;