use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::ci::CI;
use crate::config::Config;
use crate::environment::Env;
use crate::environment::EnvValue;
use crate::module::Instance;
use crate::secrets::Secret;
use crate::tasks::Task;
use crate::tasks::{
Input, Mapping, ProjectReference, ScriptShell, ShellOptions, TaskDependency, TaskNode,
};
use cuenv_hooks::{Hook, Hooks};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum HookItem {
TaskRef(TaskRef),
Match(MatchHook),
Task(Box<Task>),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct MatchHook {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(rename = "match")]
pub matcher: TaskMatcher,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TaskRef {
#[serde(rename = "ref")]
pub ref_: String,
}
impl TaskRef {
pub fn parse(&self) -> Option<(String, String)> {
let ref_str = self.ref_.strip_prefix('#')?;
let parts: Vec<&str> = ref_str.splitn(2, ':').collect();
if parts.len() == 2 {
let project = parts[0];
let task = parts[1];
if !project.is_empty() && !task.is_empty() {
Some((project.to_string(), task.to_string()))
} else {
None
}
} else {
None
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TaskMatcher {
#[serde(skip_serializing_if = "Option::is_none")]
pub labels: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub args: Option<Vec<ArgMatcher>>,
#[serde(default = "default_true")]
pub parallel: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ArgMatcher {
#[serde(skip_serializing_if = "Option::is_none")]
pub contains: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub matches: Option<String>,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct Base {
#[serde(skip_serializing_if = "Option::is_none")]
pub config: Option<Config>,
#[serde(skip_serializing_if = "Option::is_none")]
pub env: Option<Env>,
#[serde(skip_serializing_if = "Option::is_none")]
pub formatters: Option<Formatters>,
#[serde(skip_serializing_if = "Option::is_none")]
pub runtime: Option<Runtime>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hooks: Option<Hooks>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct Formatters {
#[serde(skip_serializing_if = "Option::is_none")]
pub rust: Option<RustFormatter>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nix: Option<NixFormatter>,
#[serde(skip_serializing_if = "Option::is_none")]
pub go: Option<GoFormatter>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cue: Option<CueFormatter>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RustFormatter {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_rs_includes")]
pub includes: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub edition: Option<String>,
}
impl Default for RustFormatter {
fn default() -> Self {
Self {
enabled: true,
includes: default_rs_includes(),
edition: None,
}
}
}
fn default_rs_includes() -> Vec<String> {
vec!["*.rs".to_string()]
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum NixFormatterTool {
#[default]
Nixfmt,
Alejandra,
}
impl NixFormatterTool {
#[must_use]
pub fn command(&self) -> &'static str {
match self {
Self::Nixfmt => "nixfmt",
Self::Alejandra => "alejandra",
}
}
#[must_use]
pub fn check_flag(&self) -> &'static str {
match self {
Self::Nixfmt => "--check",
Self::Alejandra => "-c",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct NixFormatter {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_nix_includes")]
pub includes: Vec<String>,
#[serde(default)]
pub tool: NixFormatterTool,
}
impl Default for NixFormatter {
fn default() -> Self {
Self {
enabled: true,
includes: default_nix_includes(),
tool: NixFormatterTool::default(),
}
}
}
fn default_nix_includes() -> Vec<String> {
vec!["*.nix".to_string()]
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct GoFormatter {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_go_includes")]
pub includes: Vec<String>,
}
impl Default for GoFormatter {
fn default() -> Self {
Self {
enabled: true,
includes: default_go_includes(),
}
}
}
fn default_go_includes() -> Vec<String> {
vec!["*.go".to_string()]
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CueFormatter {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_cue_includes")]
pub includes: Vec<String>,
}
impl Default for CueFormatter {
fn default() -> Self {
Self {
enabled: true,
includes: default_cue_includes(),
}
}
}
fn default_cue_includes() -> Vec<String> {
vec!["*.cue".to_string()]
}
pub type Ignore = HashMap<String, IgnoreValue>;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FileMode {
#[default]
Managed,
Scaffold,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct FormatConfig {
#[serde(default = "default_indent")]
pub indent: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub indent_size: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub line_width: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trailing_comma: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub semicolons: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quotes: Option<String>,
}
fn default_indent() -> String {
"space".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProjectFile {
pub content: String,
pub language: String,
#[serde(default)]
pub mode: FileMode,
#[serde(default)]
pub format: FormatConfig,
#[serde(default)]
pub gitignore: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct CodegenConfig {
#[serde(default)]
pub files: HashMap<String, ProjectFile>,
#[serde(default)]
pub context: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum IgnoreValue {
Patterns(Vec<String>),
Extended(IgnoreEntry),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct IgnoreEntry {
pub patterns: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
}
impl IgnoreValue {
#[must_use]
pub fn patterns(&self) -> &[String] {
match self {
Self::Patterns(patterns) => patterns,
Self::Extended(entry) => &entry.patterns,
}
}
#[must_use]
pub fn filename(&self) -> Option<&str> {
match self {
Self::Patterns(_) => None,
Self::Extended(entry) => entry.filename.as_deref(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct DirectoryRules {
#[serde(skip_serializing_if = "Option::is_none")]
pub ignore: Option<Ignore>,
#[serde(skip_serializing_if = "Option::is_none")]
pub owners: Option<RulesOwners>,
#[serde(skip_serializing_if = "Option::is_none")]
pub editorconfig: Option<EditorConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct RulesOwners {
#[serde(default)]
pub rules: HashMap<String, crate::owners::OwnerRule>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct EditorConfig {
#[serde(flatten)]
pub sections: std::collections::BTreeMap<String, EditorConfigSection>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "snake_case")]
pub struct EditorConfigSection {
#[serde(skip_serializing_if = "Option::is_none")]
pub indent_style: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub indent_size: Option<EditorConfigValue>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tab_width: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_of_line: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub charset: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trim_trailing_whitespace: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub insert_final_newline: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_line_length: Option<EditorConfigValue>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum EditorConfigValue {
Int(u32),
String(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum Runtime {
Nix(NixRuntime),
Devenv(DevenvRuntime),
Container(ContainerRuntime),
Dagger(DaggerRuntime),
Oci(OciRuntime),
Tools(Box<ToolsRuntime>),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NixRuntime {
#[serde(default = "default_flake")]
pub flake: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub output: Option<String>,
}
impl Default for NixRuntime {
fn default() -> Self {
Self {
flake: default_flake(),
output: None,
}
}
}
fn default_flake() -> String {
".".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct DevenvRuntime {
#[serde(default = "default_flake")]
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ContainerRuntime {
pub image: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct DaggerRuntime {
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub from: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub secrets: Vec<DaggerSecret>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cache: Vec<DaggerCacheMount>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DaggerSecret {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub env_var: Option<String>,
pub resolver: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DaggerCacheMount {
pub path: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub struct OciRuntime {
#[serde(default)]
pub platforms: Vec<String>,
#[serde(default)]
pub images: Vec<OciImage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_dir: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OciImage {
pub image: String,
#[serde(rename = "as", skip_serializing_if = "Option::is_none")]
pub as_name: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extract: Vec<OciExtract>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OciExtract {
pub path: String,
#[serde(rename = "as", skip_serializing_if = "Option::is_none")]
pub as_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct GitHubProviderConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub token: Option<Secret>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct ToolsRuntime {
#[serde(default)]
pub platforms: Vec<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub flakes: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub github: Option<GitHubProviderConfig>,
#[serde(default)]
pub tools: HashMap<String, ToolSpec>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_dir: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum ToolSpec {
Version(String),
Full(ToolConfig),
}
impl ToolSpec {
#[must_use]
pub fn version(&self) -> &str {
match self {
Self::Version(v) => v,
Self::Full(c) => &c.version,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ToolConfig {
pub version: String,
#[serde(rename = "as", skip_serializing_if = "Option::is_none")]
pub as_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<SourceConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub overrides: Vec<SourceOverride>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SourceOverride {
#[serde(skip_serializing_if = "Option::is_none")]
pub os: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub arch: Option<String>,
pub source: SourceConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum SourceConfig {
Oci {
image: String,
path: String,
},
#[serde(rename = "github")]
GitHub {
repo: String,
#[serde(default, rename = "tagPrefix")]
tag_prefix: String,
#[serde(skip_serializing_if = "Option::is_none")]
tag: Option<String>,
asset: String,
#[serde(skip_serializing_if = "Option::is_none")]
path: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
extract: Vec<GitHubExtract>,
},
Nix {
flake: String,
package: String,
#[serde(skip_serializing_if = "Option::is_none")]
output: Option<String>,
},
Rustup {
toolchain: String,
#[serde(default = "default_rustup_profile")]
profile: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
components: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
targets: Vec<String>,
},
#[serde(rename = "url")]
Url {
url: String,
#[serde(skip_serializing_if = "Option::is_none")]
path: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
extract: Vec<GitHubExtract>,
},
}
fn default_rustup_profile() -> String {
"default".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum GitHubExtract {
Bin {
path: String,
#[serde(rename = "as", skip_serializing_if = "Option::is_none")]
as_name: Option<String>,
},
Lib {
path: String,
#[serde(skip_serializing_if = "Option::is_none")]
env: Option<String>,
},
Include {
path: String,
},
PkgConfig {
path: String,
},
File {
path: String,
#[serde(skip_serializing_if = "Option::is_none")]
env: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct Command {
pub command: String,
#[serde(default)]
pub args: Vec<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct Script {
pub script: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub script_shell: Option<ScriptShell>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shell_options: Option<ShellOptions>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum Entrypoint {
Task(Box<Task>),
Script(Script),
Command(Command),
}
impl Default for Entrypoint {
fn default() -> Self {
Entrypoint::Command(Command::default())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct Service {
#[serde(rename = "type", default = "default_service_type")]
pub service_type: String,
#[serde(default)]
pub entrypoint: Entrypoint,
#[serde(default)]
pub env: HashMap<String, EnvValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dir: Option<String>,
#[serde(default, rename = "dependsOn")]
pub depends_on: Vec<TaskDependency>,
#[serde(default)]
pub labels: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime: Option<Runtime>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub readiness: Option<Readiness>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub restart: Option<RestartPolicy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub watch: Option<ServiceWatch>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logs: Option<ServiceLogs>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shutdown: Option<Shutdown>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout: Option<String>,
}
impl Service {
#[must_use]
pub fn primary_command(&self) -> Option<&str> {
match &self.entrypoint {
Entrypoint::Task(task) => {
if task.command.is_empty() {
None
} else {
Some(task.command.as_str())
}
}
Entrypoint::Command(cmd) => Some(cmd.command.as_str()),
Entrypoint::Script(_) => None,
}
}
}
fn default_service_type() -> String {
"service".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ImageOutputRef {
#[serde(rename = "cuenvOutputRef")]
pub cuenv_output_ref: bool,
#[serde(rename = "cuenvImage")]
pub cuenv_image: String,
#[serde(rename = "cuenvOutput")]
pub cuenv_output: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ContainerImage {
#[serde(rename = "type", default = "default_image_type")]
pub image_type: String,
#[serde(rename = "ref")]
pub ref_output: ImageOutputRef,
pub digest: ImageOutputRef,
pub context: String,
#[serde(default = "default_dockerfile")]
pub dockerfile: String,
#[serde(
default,
rename = "buildArgs",
skip_serializing_if = "HashMap::is_empty"
)]
pub build_args: HashMap<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub registry: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository: Option<String>,
#[serde(default)]
pub platform: Vec<String>,
#[serde(default, rename = "dependsOn")]
pub depends_on: Vec<TaskDependency>,
#[serde(default)]
pub labels: Vec<String>,
#[serde(default)]
pub inputs: Vec<Input>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
fn default_image_type() -> String {
"image".to_string()
}
fn default_dockerfile() -> String {
"Dockerfile".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind")]
pub enum Readiness {
#[serde(rename = "port")]
Port(ReadinessPort),
#[serde(rename = "http")]
Http(ReadinessHttp),
#[serde(rename = "log")]
Log(ReadinessLog),
#[serde(rename = "command")]
Command(ReadinessCommand),
#[serde(rename = "delay")]
Delay(ReadinessDelay),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct ReadinessCommon {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interval: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout: Option<String>,
#[serde(
default,
rename = "initialDelay",
skip_serializing_if = "Option::is_none"
)]
pub initial_delay: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ReadinessPort {
#[serde(flatten)]
pub common: ReadinessCommon,
pub port: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ReadinessHttp {
#[serde(flatten)]
pub common: ReadinessCommon,
pub url: String,
#[serde(
default,
rename = "expectStatus",
skip_serializing_if = "Option::is_none"
)]
pub expect_status: Option<Vec<u16>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ReadinessLog {
#[serde(flatten)]
pub common: ReadinessCommon,
pub pattern: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ReadinessCommand {
#[serde(flatten)]
pub common: ReadinessCommon,
pub command: String,
#[serde(default)]
pub args: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ReadinessDelay {
pub delay: String,
}
impl Readiness {
#[must_use]
pub fn common_fields(&self) -> Option<&ReadinessCommon> {
match self {
Self::Port(p) => Some(&p.common),
Self::Http(h) => Some(&h.common),
Self::Log(l) => Some(&l.common),
Self::Command(c) => Some(&c.common),
Self::Delay(_) => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RestartPolicy {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backoff: Option<BackoffConfig>,
#[serde(
default,
rename = "maxRestarts",
skip_serializing_if = "Option::is_none"
)]
pub max_restarts: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub window: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BackoffConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub initial: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub factor: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ServiceWatch {
pub paths: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ignore: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub debounce: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rebuild: Option<Vec<TaskDependency>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ServiceLogs {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prefix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub persist: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Shutdown {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signal: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct Project {
#[serde(skip_serializing_if = "Option::is_none")]
pub config: Option<Config>,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub env: Option<Env>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hooks: Option<Hooks>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ci: Option<CI>,
#[serde(default)]
pub tasks: HashMap<String, TaskNode>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub services: HashMap<String, Service>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub images: HashMap<String, ContainerImage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub codegen: Option<CodegenConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub runtime: Option<Runtime>,
#[serde(skip_serializing_if = "Option::is_none")]
pub formatters: Option<Formatters>,
}
impl Project {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
..Self::default()
}
}
pub fn on_enter_hooks_map(&self) -> HashMap<String, Hook> {
self.hooks
.as_ref()
.and_then(|h| h.on_enter.as_ref())
.cloned()
.unwrap_or_default()
}
pub fn on_enter_hooks(&self) -> Vec<Hook> {
let map = self.on_enter_hooks_map();
let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
hooks.into_iter().map(|(_, h)| h).collect()
}
pub fn on_exit_hooks_map(&self) -> HashMap<String, Hook> {
self.hooks
.as_ref()
.and_then(|h| h.on_exit.as_ref())
.cloned()
.unwrap_or_default()
}
pub fn on_exit_hooks(&self) -> Vec<Hook> {
let map = self.on_exit_hooks_map();
let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
hooks.into_iter().map(|(_, h)| h).collect()
}
pub fn pre_push_hooks_map(&self) -> HashMap<String, Hook> {
self.hooks
.as_ref()
.and_then(|h| h.pre_push.as_ref())
.cloned()
.unwrap_or_default()
}
pub fn pre_push_hooks(&self) -> Vec<Hook> {
let map = self.pre_push_hooks_map();
let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
hooks.into_iter().map(|(_, h)| h).collect()
}
#[must_use]
pub fn with_implicit_tasks(self) -> Self {
self
}
pub fn expand_cross_project_references(&mut self) {
for (_, task_node) in self.tasks.iter_mut() {
Self::expand_task_node(task_node);
}
}
fn expand_task_node(node: &mut TaskNode) {
match node {
TaskNode::Task(task) => Self::expand_task(task),
TaskNode::Group(group) => {
for sub_node in group.children.values_mut() {
Self::expand_task_node(sub_node);
}
}
TaskNode::Sequence(steps) => {
for sub_node in steps {
Self::expand_task_node(sub_node);
}
}
}
}
fn expand_task(task: &mut Task) {
let mut new_inputs = Vec::new();
let mut implicit_deps = Vec::new();
for input in &task.inputs {
match input {
Input::Path(path) if path.starts_with('#') => {
let parts: Vec<&str> = path[1..].split(':').collect();
if parts.len() >= 3 {
let project = parts[0].to_string();
let task_name = parts[1].to_string();
let file_path = parts[2..].join(":");
new_inputs.push(Input::Project(ProjectReference {
project: project.clone(),
task: task_name.clone(),
map: vec![Mapping {
from: file_path.clone(),
to: file_path,
}],
}));
implicit_deps.push(format!("#{}:{}", project, task_name));
} else if parts.len() == 2 {
new_inputs.push(input.clone());
} else {
new_inputs.push(input.clone());
}
}
Input::Project(proj_ref) => {
implicit_deps.push(format!("#{}:{}", proj_ref.project, proj_ref.task));
new_inputs.push(input.clone());
}
_ => new_inputs.push(input.clone()),
}
}
task.inputs = new_inputs;
for dep in implicit_deps {
if !task.depends_on.iter().any(|d| d.task_name() == dep) {
task.depends_on
.push(crate::tasks::TaskDependency::from_name(dep));
}
}
}
}
impl TryFrom<&Instance> for Project {
type Error = crate::Error;
fn try_from(instance: &Instance) -> Result<Self, Self::Error> {
let mut project: Project = instance.deserialize()?;
project.expand_cross_project_references();
Ok(project)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tasks::{TaskDependency, TaskGroup, TaskNode};
use crate::test_utils::create_test_hook;
#[test]
fn test_service_type_defaults_to_service_when_omitted() {
let service: Service = serde_json::from_value(serde_json::json!({
"entrypoint": { "command": "echo", "args": ["hello"] }
}))
.expect("service should deserialize without explicit type");
assert_eq!(service.service_type, "service");
}
#[test]
fn test_service_entrypoint_command_variant() {
let service: Service = serde_json::from_value(serde_json::json!({
"entrypoint": { "command": "echo", "args": ["hi"] }
}))
.expect("should deserialize command entrypoint");
match &service.entrypoint {
Entrypoint::Task(task) => {
assert_eq!(task.command, "echo");
}
Entrypoint::Command(cmd) => assert_eq!(cmd.command, "echo"),
Entrypoint::Script(_) => panic!("expected Task or Command, got Script"),
}
}
#[test]
fn test_service_entrypoint_script_variant() {
let service: Service = serde_json::from_value(serde_json::json!({
"entrypoint": { "script": "echo hi" }
}))
.expect("should deserialize script entrypoint");
match &service.entrypoint {
Entrypoint::Task(task) => {
assert_eq!(task.script.as_deref(), Some("echo hi"));
}
Entrypoint::Script(s) => assert_eq!(s.script, "echo hi"),
Entrypoint::Command(_) => panic!("expected Task or Script, got Command"),
}
}
#[test]
fn test_expand_cross_project_references() {
let task = Task {
inputs: vec![Input::Path("#myproj:build:dist/app.js".to_string())],
..Default::default()
};
let mut cuenv = Project::new("test");
cuenv
.tasks
.insert("deploy".into(), TaskNode::Task(Box::new(task)));
cuenv.expand_cross_project_references();
let task_def = cuenv.tasks.get("deploy").unwrap();
let task = task_def.as_task().unwrap();
assert_eq!(task.inputs.len(), 1);
match &task.inputs[0] {
Input::Project(proj_ref) => {
assert_eq!(proj_ref.project, "myproj");
assert_eq!(proj_ref.task, "build");
assert_eq!(proj_ref.map.len(), 1);
assert_eq!(proj_ref.map[0].from, "dist/app.js");
assert_eq!(proj_ref.map[0].to, "dist/app.js");
}
_ => panic!("Expected ProjectReference"),
}
assert_eq!(task.depends_on.len(), 1);
assert_eq!(task.depends_on[0].task_name(), "#myproj:build");
}
#[test]
fn test_task_ref_parse_valid() {
let task_ref = TaskRef {
ref_: "#projen-generator:types".to_string(),
};
let parsed = task_ref.parse();
assert!(parsed.is_some());
let (project, task) = parsed.unwrap();
assert_eq!(project, "projen-generator");
assert_eq!(task, "types");
}
#[test]
fn test_task_ref_parse_with_dots() {
let task_ref = TaskRef {
ref_: "#my-project:bun.install".to_string(),
};
let parsed = task_ref.parse();
assert!(parsed.is_some());
let (project, task) = parsed.unwrap();
assert_eq!(project, "my-project");
assert_eq!(task, "bun.install");
}
#[test]
fn test_task_ref_parse_no_hash() {
let task_ref = TaskRef {
ref_: "project:task".to_string(),
};
let parsed = task_ref.parse();
assert!(parsed.is_none());
}
#[test]
fn test_task_ref_parse_no_colon() {
let task_ref = TaskRef {
ref_: "#project-only".to_string(),
};
let parsed = task_ref.parse();
assert!(parsed.is_none());
}
#[test]
fn test_task_ref_parse_empty_project() {
let task_ref = TaskRef {
ref_: "#:task".to_string(),
};
assert!(task_ref.parse().is_none());
}
#[test]
fn test_task_ref_parse_empty_task() {
let task_ref = TaskRef {
ref_: "#project:".to_string(),
};
assert!(task_ref.parse().is_none());
}
#[test]
fn test_task_ref_parse_both_empty() {
let task_ref = TaskRef {
ref_: "#:".to_string(),
};
assert!(task_ref.parse().is_none());
}
#[test]
fn test_task_ref_parse_multiple_colons() {
let task_ref = TaskRef {
ref_: "#project:task:extra".to_string(),
};
let parsed = task_ref.parse();
assert!(parsed.is_some());
let (project, task) = parsed.unwrap();
assert_eq!(project, "project");
assert_eq!(task, "task:extra");
}
#[test]
fn test_task_ref_parse_unicode() {
let task_ref = TaskRef {
ref_: "#项目名:任务名".to_string(),
};
let parsed = task_ref.parse();
assert!(parsed.is_some());
let (project, task) = parsed.unwrap();
assert_eq!(project, "项目名");
assert_eq!(task, "任务名");
}
#[test]
fn test_task_ref_parse_special_characters() {
let task_ref = TaskRef {
ref_: "#my-project_v2:build.ci-test".to_string(),
};
let parsed = task_ref.parse();
assert!(parsed.is_some());
let (project, task) = parsed.unwrap();
assert_eq!(project, "my-project_v2");
assert_eq!(task, "build.ci-test");
}
#[test]
fn test_hook_item_task_ref_deserialization() {
let json = "{\"ref\": \"#other-project:build\"}";
let hook_item: HookItem = serde_json::from_str(json).unwrap();
match hook_item {
HookItem::TaskRef(task_ref) => {
assert_eq!(task_ref.ref_, "#other-project:build");
let (project, task) = task_ref.parse().unwrap();
assert_eq!(project, "other-project");
assert_eq!(task, "build");
}
_ => panic!("Expected HookItem::TaskRef"),
}
}
#[test]
fn test_hook_item_match_deserialization() {
let json = r#"{
"name": "projen",
"match": {
"labels": ["codegen", "projen"]
}
}"#;
let hook_item: HookItem = serde_json::from_str(json).unwrap();
match hook_item {
HookItem::Match(match_hook) => {
assert_eq!(match_hook.name, Some("projen".to_string()));
assert_eq!(
match_hook.matcher.labels,
Some(vec!["codegen".to_string(), "projen".to_string()])
);
}
_ => panic!("Expected HookItem::Match"),
}
}
#[test]
fn test_hook_item_match_with_parallel_false() {
let json = r#"{
"match": {
"labels": ["build"],
"parallel": false
}
}"#;
let hook_item: HookItem = serde_json::from_str(json).unwrap();
match hook_item {
HookItem::Match(match_hook) => {
assert!(match_hook.name.is_none());
assert!(!match_hook.matcher.parallel);
}
_ => panic!("Expected HookItem::Match"),
}
}
#[test]
fn test_hook_item_inline_task_deserialization() {
let json = r#"{
"command": "echo",
"args": ["hello"]
}"#;
let hook_item: HookItem = serde_json::from_str(json).unwrap();
match hook_item {
HookItem::Task(task) => {
assert_eq!(task.command, "echo");
assert_eq!(task.args, vec!["hello"]);
}
_ => panic!("Expected HookItem::Task"),
}
}
#[test]
fn test_task_matcher_deserialization() {
let json = r#"{
"labels": ["projen", "codegen"],
"parallel": true
}"#;
let matcher: TaskMatcher = serde_json::from_str(json).unwrap();
assert_eq!(
matcher.labels,
Some(vec!["projen".to_string(), "codegen".to_string()])
);
assert!(matcher.parallel);
}
#[test]
fn test_task_matcher_defaults() {
let json = r#"{}"#;
let matcher: TaskMatcher = serde_json::from_str(json).unwrap();
assert!(matcher.labels.is_none());
assert!(matcher.command.is_none());
assert!(matcher.args.is_none());
assert!(matcher.parallel); }
#[test]
fn test_task_matcher_with_command() {
let json = r#"{
"command": "prisma",
"args": [{"contains": "generate"}]
}"#;
let matcher: TaskMatcher = serde_json::from_str(json).unwrap();
assert_eq!(matcher.command, Some("prisma".to_string()));
let args = matcher.args.unwrap();
assert_eq!(args.len(), 1);
assert_eq!(args[0].contains, Some("generate".to_string()));
}
#[test]
fn test_expand_multiple_cross_project_references() {
let task = Task {
inputs: vec![
Input::Path("#projA:build:dist/lib.js".to_string()),
Input::Path("#projB:compile:out/types.d.ts".to_string()),
Input::Path("src/**/*.ts".to_string()), ],
..Default::default()
};
let mut cuenv = Project::new("test");
cuenv
.tasks
.insert("bundle".into(), TaskNode::Task(Box::new(task)));
cuenv.expand_cross_project_references();
let task_def = cuenv.tasks.get("bundle").unwrap();
let task = task_def.as_task().unwrap();
assert_eq!(task.inputs.len(), 3);
assert_eq!(task.depends_on.len(), 2);
assert!(
task.depends_on
.iter()
.any(|d| d.task_name() == "#projA:build")
);
assert!(
task.depends_on
.iter()
.any(|d| d.task_name() == "#projB:compile")
);
}
#[test]
fn test_expand_cross_project_in_task_group() {
let task1 = Task {
command: "step1".to_string(),
inputs: vec![Input::Path("#projA:build:dist/lib.js".to_string())],
..Default::default()
};
let task2 = Task {
command: "step2".to_string(),
inputs: vec![Input::Path("#projB:compile:out/types.d.ts".to_string())],
..Default::default()
};
let mut cuenv = Project::new("test");
cuenv.tasks.insert(
"pipeline".into(),
TaskNode::Sequence(vec![
TaskNode::Task(Box::new(task1)),
TaskNode::Task(Box::new(task2)),
]),
);
cuenv.expand_cross_project_references();
match cuenv.tasks.get("pipeline").unwrap() {
TaskNode::Sequence(steps) => {
match &steps[0] {
TaskNode::Task(task) => {
assert!(
task.depends_on
.iter()
.any(|d| d.task_name() == "#projA:build")
);
}
_ => panic!("Expected single task"),
}
match &steps[1] {
TaskNode::Task(task) => {
assert!(
task.depends_on
.iter()
.any(|d| d.task_name() == "#projB:compile")
);
}
_ => panic!("Expected single task"),
}
}
_ => panic!("Expected task list"),
}
}
#[test]
fn test_expand_cross_project_in_parallel_group() {
let task1 = Task {
command: "taskA".to_string(),
inputs: vec![Input::Path("#projA:build:lib.js".to_string())],
..Default::default()
};
let task2 = Task {
command: "taskB".to_string(),
inputs: vec![Input::Path("#projB:build:types.d.ts".to_string())],
..Default::default()
};
let mut parallel_tasks = HashMap::new();
parallel_tasks.insert("a".to_string(), TaskNode::Task(Box::new(task1)));
parallel_tasks.insert("b".to_string(), TaskNode::Task(Box::new(task2)));
let mut cuenv = Project::new("test");
cuenv.tasks.insert(
"parallel".into(),
TaskNode::Group(TaskGroup {
type_: "group".to_string(),
children: parallel_tasks,
depends_on: vec![],
description: None,
max_concurrency: None,
}),
);
cuenv.expand_cross_project_references();
match cuenv.tasks.get("parallel").unwrap() {
TaskNode::Group(group) => {
match group.children.get("a").unwrap() {
TaskNode::Task(task) => {
assert!(
task.depends_on
.iter()
.any(|d| d.task_name() == "#projA:build")
);
}
_ => panic!("Expected single task"),
}
match group.children.get("b").unwrap() {
TaskNode::Task(task) => {
assert!(
task.depends_on
.iter()
.any(|d| d.task_name() == "#projB:build")
);
}
_ => panic!("Expected single task"),
}
}
_ => panic!("Expected parallel group"),
}
}
#[test]
fn test_no_duplicate_implicit_dependencies() {
let task = Task {
depends_on: vec![TaskDependency::from_name("#myproj:build")],
inputs: vec![Input::Path("#myproj:build:dist/app.js".to_string())],
..Default::default()
};
let mut cuenv = Project::new("test");
cuenv
.tasks
.insert("deploy".into(), TaskNode::Task(Box::new(task)));
cuenv.expand_cross_project_references();
let task_def = cuenv.tasks.get("deploy").unwrap();
let task = task_def.as_task().unwrap();
assert_eq!(task.depends_on.len(), 1);
assert_eq!(task.depends_on[0].task_name(), "#myproj:build");
}
#[test]
fn test_on_enter_hooks_ordering() {
let mut on_enter = HashMap::new();
on_enter.insert("hook_c".to_string(), create_test_hook(300, "echo c"));
on_enter.insert("hook_a".to_string(), create_test_hook(100, "echo a"));
on_enter.insert("hook_b".to_string(), create_test_hook(200, "echo b"));
let mut cuenv = Project::new("test");
cuenv.hooks = Some(Hooks {
on_enter: Some(on_enter),
on_exit: None,
pre_push: None,
});
let hooks = cuenv.on_enter_hooks();
assert_eq!(hooks.len(), 3);
assert_eq!(hooks[0].order, 100);
assert_eq!(hooks[1].order, 200);
assert_eq!(hooks[2].order, 300);
}
#[test]
fn test_on_enter_hooks_same_order_sort_by_name() {
let mut on_enter = HashMap::new();
on_enter.insert("z_hook".to_string(), create_test_hook(100, "echo z"));
on_enter.insert("a_hook".to_string(), create_test_hook(100, "echo a"));
let cuenv = Project {
name: "test".to_string(),
hooks: Some(Hooks {
on_enter: Some(on_enter),
on_exit: None,
pre_push: None,
}),
..Default::default()
};
let hooks = cuenv.on_enter_hooks();
assert_eq!(hooks.len(), 2);
assert_eq!(hooks[0].command, "echo a");
assert_eq!(hooks[1].command, "echo z");
}
#[test]
fn test_empty_hooks() {
let cuenv = Project::new("test");
let on_enter = cuenv.on_enter_hooks();
let on_exit = cuenv.on_exit_hooks();
assert!(on_enter.is_empty());
assert!(on_exit.is_empty());
}
#[test]
fn test_project_deserialization_with_script_tasks() {
let json = r#"{
"name": "cuenv",
"hooks": {
"onEnter": {
"nix": {
"order": 10,
"propagate": false,
"command": "nix",
"args": ["print-dev-env"],
"inputs": ["flake.nix", "flake.lock"],
"source": true
}
}
},
"tasks": {
"pwd": { "command": "pwd" },
"check": {
"command": "nix",
"args": ["flake", "check"],
"inputs": ["flake.nix"]
},
"fmt": {
"type": "group",
"fix": {
"command": "treefmt",
"inputs": [".config"]
},
"check": {
"command": "treefmt",
"args": ["--fail-on-change"],
"inputs": [".config"]
}
},
"cross": {
"type": "group",
"linux": {
"script": "echo building for linux",
"inputs": ["Cargo.toml"]
}
},
"docs": {
"type": "group",
"build": {
"command": "bash",
"args": ["-c", "bun install"],
"inputs": ["docs"],
"outputs": ["docs/dist"]
},
"deploy": {
"command": "bash",
"args": ["-c", "wrangler deploy"],
"dependsOn": ["docs.build"],
"inputs": [{"task": "docs.build"}]
}
}
}
}"#;
let result: Result<Project, _> = serde_json::from_str(json);
match result {
Ok(project) => {
assert_eq!(project.name, "cuenv");
assert_eq!(project.tasks.len(), 5);
assert!(project.tasks.contains_key("pwd"));
assert!(project.tasks.contains_key("cross"));
let cross = project.tasks.get("cross").unwrap();
assert!(cross.is_group());
}
Err(e) => {
panic!("Failed to deserialize Project with script tasks: {}", e);
}
}
}
#[test]
fn test_deserialize_actual_cuenv_project() {
let json = match std::fs::read_to_string("/tmp/project.json") {
Ok(content) => content,
Err(_) => return, };
let result: Result<Project, _> = serde_json::from_str(&json);
match result {
Ok(project) => {
eprintln!("Project name: {}", project.name);
eprintln!("Tasks: {:?}", project.tasks.keys().collect::<Vec<_>>());
}
Err(e) => {
eprintln!("Failed: {}", e);
eprintln!("Line: {}, Col: {}", e.line(), e.column());
let lines: Vec<&str> = json.lines().collect();
let line_num = e.line();
let start = if line_num > 3 { line_num - 3 } else { 1 };
let end = std::cmp::min(line_num + 3, lines.len());
for i in start..=end {
if i <= lines.len() {
eprintln!("{}: {}", i, lines[i - 1]);
}
}
panic!("Deserialization failed");
}
}
}
}