use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use syn::{punctuated::Punctuated, Expr, Item, Lit, Meta, Token}; use thiserror::Error; use crate::{discover_environments, environment::EnvironmentError, EnvironmentResource}; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Entrypoint { pub name: String, pub function: String, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ProjectModel { pub root: PathBuf, pub environments: Vec, pub entrypoints: BTreeMap, pub default_entrypoint: String, pub required_config_file: Option, } #[derive(Clone, Debug, Error, PartialEq, Eq)] pub enum ProjectModelError { #[error("environment discovery failed: {0}")] Environment(String), #[error("Disasmer entrypoint discovery failed: {0}")] EntrypointDiscovery(String), #[error( "no Disasmer entrypoint is declared; add `#[disasmer::main]` to a function under src/" )] NoEntrypoints, #[error("unknown Disasmer entrypoint `{name}`; available entrypoints: {available:?}")] UnknownEntrypoint { name: String, available: Vec, }, } impl ProjectModel { pub fn discover_without_config(root: &Path) -> Result { let environments = discover_environments(root).map_err(|err| { ProjectModelError::Environment(match err { EnvironmentError::Read { path, source } => { format!("failed to read {}: {source}", path.display()) } }) })?; let entrypoints = discover_entrypoints(root)?; let default_entrypoint = if entrypoints.contains_key("build") { "build".to_owned() } else { entrypoints.keys().next().cloned().unwrap_or_default() }; Ok(Self { root: root.to_path_buf(), environments, entrypoints, default_entrypoint, required_config_file: None, }) } pub fn select_entrypoint(&self, name: Option<&str>) -> Result<&Entrypoint, ProjectModelError> { if self.entrypoints.is_empty() { return Err(ProjectModelError::NoEntrypoints); } let name = name.unwrap_or(&self.default_entrypoint); self.entrypoints .get(name) .ok_or_else(|| ProjectModelError::UnknownEntrypoint { name: name.to_owned(), available: self.entrypoints.keys().cloned().collect(), }) } } fn discover_entrypoints(root: &Path) -> Result, ProjectModelError> { let source_root = root.join("src"); if !source_root.is_dir() { return Ok(BTreeMap::new()); } let mut source_files = Vec::new(); collect_rust_sources(&source_root, &mut source_files)?; source_files.sort(); let mut entrypoints = BTreeMap::new(); for path in source_files { let source = fs::read_to_string(&path).map_err(|error| { ProjectModelError::EntrypointDiscovery(format!( "failed to read {}: {error}", path.display() )) })?; let syntax = syn::parse_file(&source).map_err(|error| { ProjectModelError::EntrypointDiscovery(format!( "failed to parse {}: {error}", path.display() )) })?; collect_entrypoint_items(&syntax.items, &path, &mut entrypoints)?; } Ok(entrypoints) } fn collect_rust_sources( directory: &Path, files: &mut Vec, ) -> Result<(), ProjectModelError> { let entries = fs::read_dir(directory).map_err(|error| { ProjectModelError::EntrypointDiscovery(format!( "failed to read {}: {error}", directory.display() )) })?; for entry in entries { let entry = entry.map_err(|error| { ProjectModelError::EntrypointDiscovery(format!( "failed to inspect {}: {error}", directory.display() )) })?; let path = entry.path(); let file_type = entry.file_type().map_err(|error| { ProjectModelError::EntrypointDiscovery(format!( "failed to inspect {}: {error}", path.display() )) })?; if file_type.is_dir() { collect_rust_sources(&path, files)?; } else if file_type.is_file() && path.extension().is_some_and(|ext| ext == "rs") { files.push(path); } } Ok(()) } fn collect_entrypoint_items( items: &[Item], path: &Path, entrypoints: &mut BTreeMap, ) -> Result<(), ProjectModelError> { for item in items { match item { Item::Fn(function) => { let Some(attribute) = function.attrs.iter().find(|attribute| { let segments = attribute .path() .segments .iter() .map(|segment| segment.ident.to_string()) .collect::>(); segments.as_slice() == ["disasmer", "main"] }) else { continue; }; let function_name = function.sig.ident.to_string(); let default_name = function_name .strip_suffix("_main") .unwrap_or(&function_name); let name = entrypoint_name(attribute, default_name); let entrypoint = Entrypoint { name: name.clone(), function: function_name, }; if let Some(existing) = entrypoints.insert(name.clone(), entrypoint) { return Err(ProjectModelError::EntrypointDiscovery(format!( "duplicate entrypoint `{name}` in {}; it was already declared by `{}`", path.display(), existing.function ))); } } Item::Mod(module) => { if let Some((_, nested)) = &module.content { collect_entrypoint_items(nested, path, entrypoints)?; } } _ => {} } } Ok(()) } fn entrypoint_name(attribute: &syn::Attribute, default: &str) -> String { let Meta::List(_) = &attribute.meta else { return default.to_owned(); }; let Ok(arguments) = attribute.parse_args_with(Punctuated::::parse_terminated) else { return default.to_owned(); }; arguments .into_iter() .find_map(|meta| { let Meta::NameValue(name_value) = meta else { return None; }; if !name_value.path.is_ident("name") { return None; } let Expr::Lit(expression) = name_value.value else { return None; }; let Lit::Str(value) = expression.lit else { return None; }; Some(value.value()) }) .unwrap_or_else(|| default.to_owned()) } #[cfg(test)] mod tests { use std::fs; use super::*; #[test] fn project_works_without_hand_written_configuration_file() { let temp = tempfile::tempdir().unwrap(); fs::create_dir_all(temp.path().join("envs/linux")).unwrap(); fs::create_dir_all(temp.path().join("src")).unwrap(); fs::write( temp.path().join("envs/linux/Containerfile"), "FROM alpine\n", ) .unwrap(); fs::write( temp.path().join("src/main.rs"), "#[disasmer::main]\npub fn build_main() {}\n", ) .unwrap(); let model = ProjectModel::discover_without_config(temp.path()).unwrap(); assert_eq!(model.required_config_file, None); assert_eq!(model.environments[0].name, "linux"); assert_eq!(model.select_entrypoint(None).unwrap().name, "build"); } #[test] fn project_can_define_multiple_default_entrypoints() { let temp = tempfile::tempdir().unwrap(); fs::create_dir_all(temp.path().join("src/nested")).unwrap(); fs::write( temp.path().join("src/lib.rs"), "#[disasmer::main(name = \"check\")]\npub fn test_main() {}\n", ) .unwrap(); fs::write( temp.path().join("src/nested/release.rs"), "#[disasmer::main]\npub fn release_main() {}\n", ) .unwrap(); let model = ProjectModel::discover_without_config(temp.path()).unwrap(); assert_eq!( model.select_entrypoint(Some("check")).unwrap().function, "test_main" ); assert_eq!( model.select_entrypoint(Some("release")).unwrap().function, "release_main" ); } #[test] fn unknown_entrypoint_lists_available_choices() { let temp = tempfile::tempdir().unwrap(); fs::create_dir_all(temp.path().join("src")).unwrap(); fs::write( temp.path().join("src/main.rs"), "#[disasmer::main]\npub fn build_main() {}\n", ) .unwrap(); let model = ProjectModel::discover_without_config(temp.path()).unwrap(); let error = model.select_entrypoint(Some("deploy")).unwrap_err(); assert!(matches!(error, ProjectModelError::UnknownEntrypoint { .. })); } #[test] fn project_without_declared_entrypoint_does_not_invent_product_surfaces() { let temp = tempfile::tempdir().unwrap(); fs::create_dir_all(temp.path().join("src")).unwrap(); fs::write(temp.path().join("src/main.rs"), "fn main() {}\n").unwrap(); let model = ProjectModel::discover_without_config(temp.path()).unwrap(); assert!(model.entrypoints.is_empty()); assert_eq!(model.default_entrypoint, ""); assert_eq!( model.select_entrypoint(None).unwrap_err(), ProjectModelError::NoEntrypoints ); } }