Publish Clusterflux 1d0b6fa filtered source
This commit is contained in:
commit
e5d609cfb2
226 changed files with 86613 additions and 0 deletions
303
crates/clusterflux-core/src/project.rs
Normal file
303
crates/clusterflux-core/src/project.rs
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
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<EnvironmentResource>,
|
||||
pub entrypoints: BTreeMap<String, Entrypoint>,
|
||||
pub default_entrypoint: String,
|
||||
pub required_config_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||
pub enum ProjectModelError {
|
||||
#[error("environment discovery failed: {0}")]
|
||||
Environment(String),
|
||||
#[error("Clusterflux entrypoint discovery failed: {0}")]
|
||||
EntrypointDiscovery(String),
|
||||
#[error(
|
||||
"no Clusterflux entrypoint is declared; add `#[clusterflux::main]` to a function under src/"
|
||||
)]
|
||||
NoEntrypoints,
|
||||
#[error("unknown Clusterflux entrypoint `{name}`; available entrypoints: {available:?}")]
|
||||
UnknownEntrypoint {
|
||||
name: String,
|
||||
available: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ProjectModel {
|
||||
pub fn discover_without_config(root: &Path) -> Result<Self, ProjectModelError> {
|
||||
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<BTreeMap<String, Entrypoint>, 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<PathBuf>,
|
||||
) -> 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<String, Entrypoint>,
|
||||
) -> 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::<Vec<_>>();
|
||||
segments.as_slice() == ["clusterflux", "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::<Meta, Token![,]>::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"),
|
||||
"#[clusterflux::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"),
|
||||
"#[clusterflux::main(name = \"check\")]\npub fn test_main() {}\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
temp.path().join("src/nested/release.rs"),
|
||||
"#[clusterflux::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"),
|
||||
"#[clusterflux::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
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue