use std::collections::BTreeSet; use std::fs; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::{Capability, Digest, Os}; #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum EnvironmentKind { Containerfile, Dockerfile, NixFlake, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct EnvironmentRequirements { pub os: Option, pub arch: Option, pub capabilities: BTreeSet, } impl EnvironmentRequirements { pub fn linux_container() -> Self { Self { os: Some(Os::Linux), arch: None, capabilities: BTreeSet::from([Capability::Containers, Capability::RootlessPodman]), } } pub fn windows_command_dev() -> Self { Self { os: Some(Os::Windows), arch: None, capabilities: BTreeSet::from([Capability::WindowsCommandDev]), } } pub fn unconstrained() -> Self { Self { os: None, arch: None, capabilities: BTreeSet::new(), } } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct EnvironmentResource { pub name: String, pub kind: EnvironmentKind, pub recipe_path: PathBuf, pub context_path: PathBuf, pub digest: Digest, pub requirements: EnvironmentRequirements, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct EnvironmentReference { pub name: String, pub byte_offset: usize, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct EnvironmentDiagnostic { pub reference: EnvironmentReference, pub message: String, } #[derive(Debug, Error)] pub enum EnvironmentError { #[error("failed to read environment resources under {path}: {source}")] Read { path: PathBuf, #[source] source: std::io::Error, }, } pub fn discover_environments( project_root: &Path, ) -> Result, EnvironmentError> { let envs_dir = project_root.join("envs"); if !envs_dir.exists() { return Ok(Vec::new()); } let mut resources = Vec::new(); let entries = fs::read_dir(&envs_dir).map_err(|source| EnvironmentError::Read { path: envs_dir.clone(), source, })?; for entry in entries { let entry = entry.map_err(|source| EnvironmentError::Read { path: envs_dir.clone(), source, })?; let path = entry.path(); if !path.is_dir() { continue; } let Some(name) = path.file_name().and_then(|name| name.to_str()) else { continue; }; if let Some(resource) = discover_one(project_root, name, &path)? { resources.push(resource); } } resources.sort_by(|left, right| left.name.cmp(&right.name)); Ok(resources) } pub fn diagnose_environment_references( source: &str, environments: &[EnvironmentResource], ) -> Vec { let known = environments .iter() .map(|environment| environment.name.as_str()) .collect::>(); find_env_macro_references(source) .into_iter() .filter(|reference| !known.contains(reference.name.as_str())) .map(|reference| EnvironmentDiagnostic { message: format!( "missing Clusterflux environment `{}`; expected envs/{}/Containerfile or envs/{}/Dockerfile", reference.name, reference.name, reference.name ), reference, }) .collect() } fn discover_one( project_root: &Path, name: &str, env_dir: &Path, ) -> Result, EnvironmentError> { let candidates = [ ("Containerfile", EnvironmentKind::Containerfile), ("Dockerfile", EnvironmentKind::Dockerfile), ("flake.nix", EnvironmentKind::NixFlake), ]; for (file_name, kind) in candidates { let recipe_path = env_dir.join(file_name); if !recipe_path.exists() { continue; } let recipe_bytes = fs::read(&recipe_path).map_err(|source| EnvironmentError::Read { path: recipe_path.clone(), source, })?; let relative_recipe = recipe_path .strip_prefix(project_root) .unwrap_or(&recipe_path) .to_string_lossy(); let digest = Digest::from_parts([ b"environment:v1".as_slice(), name.as_bytes(), format!("{kind:?}").as_bytes(), relative_recipe.as_bytes(), recipe_bytes.as_slice(), ]); let requirements = match kind { EnvironmentKind::Containerfile | EnvironmentKind::Dockerfile if name.eq_ignore_ascii_case("windows") => { EnvironmentRequirements::windows_command_dev() } EnvironmentKind::Containerfile | EnvironmentKind::Dockerfile => { EnvironmentRequirements::linux_container() } EnvironmentKind::NixFlake => EnvironmentRequirements::unconstrained(), }; return Ok(Some(EnvironmentResource { name: name.to_owned(), kind, recipe_path, context_path: env_dir.to_path_buf(), digest, requirements, })); } Ok(None) } fn find_env_macro_references(source: &str) -> Vec { let mut references = Vec::new(); let mut cursor = 0; while let Some(index) = source[cursor..].find("env!(") { let start = cursor + index; let mut pos = start + "env!(".len(); while source[pos..].starts_with(char::is_whitespace) { pos += source[pos..] .chars() .next() .map(char::len_utf8) .unwrap_or(1); } if !source[pos..].starts_with('"') { cursor = pos; continue; } pos += 1; let name_start = pos; while pos < source.len() && !source[pos..].starts_with('"') { pos += source[pos..] .chars() .next() .map(char::len_utf8) .unwrap_or(1); } if pos < source.len() { references.push(EnvironmentReference { name: source[name_start..pos].to_owned(), byte_offset: start, }); } cursor = pos.saturating_add(1); } references } #[cfg(test)] mod tests { use std::fs; use super::*; #[test] fn discovers_containerfile_environments_by_logical_name() { let temp = tempfile::tempdir().unwrap(); let linux = temp.path().join("envs/linux"); fs::create_dir_all(&linux).unwrap(); fs::write(linux.join("Containerfile"), "FROM alpine\n").unwrap(); let envs = discover_environments(temp.path()).unwrap(); assert_eq!(envs.len(), 1); assert_eq!(envs[0].name, "linux"); assert_eq!(envs[0].kind, EnvironmentKind::Containerfile); assert!(!envs[0].digest.as_str().is_empty()); } #[test] fn missing_env_macro_reference_reports_clear_diagnostic() { let source = r#"fn main() { let _ = env!("windows"); }"#; let diagnostics = diagnose_environment_references(source, &[]); assert_eq!(diagnostics.len(), 1); assert!(diagnostics[0] .message .contains("envs/windows/Containerfile")); } #[test] fn windows_environment_name_uses_windows_development_requirements() { let temp = tempfile::tempdir().unwrap(); let windows = temp.path().join("envs/windows"); fs::create_dir_all(&windows).unwrap(); fs::write( windows.join("Dockerfile"), "# user-attached windows dev contract\n", ) .unwrap(); let envs = discover_environments(temp.path()).unwrap(); assert_eq!(envs[0].name, "windows"); assert_eq!(envs[0].requirements.os, Some(Os::Windows)); assert!(envs[0] .requirements .capabilities .contains(&Capability::WindowsCommandDev)); } }