flux-platform 1.0.1

A local-first, AI-native developer automation platform: build, test, package, and deploy from a single .flux file, and make your repository legible to AI agents.
//! The abstract syntax tree produced by parsing a `.flux` file.

use std::collections::HashMap;
use std::time::Duration;

/// A fully parsed `.flux` configuration.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FluxConfig {
    /// The declared project name, if any (`project "my-app"`).
    pub project: Option<String>,
    /// The declared language, if any (`language rust`).
    pub language: Option<String>,
    /// A build environment / container image, if declared.
    pub environment: Option<Environment>,
    /// Declared secret names (`secret DATABASE_URL`).
    pub secrets: Vec<String>,
    /// A deployment target, if declared.
    pub deployment: Option<Deployment>,
    /// Modules spliced into the pipeline (`use rust-library`), in order.
    pub uses: Vec<String>,
    /// Runner pools declared for scheduling (`runners { pool "gpu" { ... } }`).
    pub runner_pools: Vec<RunnerPool>,
    /// Organization policies (`policy production { require tests ... }`).
    pub policies: Vec<Policy>,
    /// Execution settings declared inside `pipeline { … }` (timeout, parallel).
    pub execution: Execution,
    /// The pipeline steps (order as written; execution order comes from the graph).
    pub steps: Vec<Step>,
}

/// Pipeline-wide execution settings, declared as fields of `pipeline { … }`.
///
/// Both are `None` when the file says nothing, which is what lets the engine
/// distinguish "the author chose this" from "use the built-in default".
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Execution {
    /// Default wall-clock limit for steps that declare no `timeout` of their own.
    pub timeout: Option<Timeout>,
    /// Cap on how many steps may run at once.
    pub parallel: Option<u32>,
}

/// A declared wall-clock limit for a step.
///
/// `Off` is a distinct state rather than a very large duration: "this step runs
/// as long as it takes" is a decision worth reading in the file, and a sentinel
/// duration would be indistinguishable from someone typing `timeout "9999h"`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Timeout {
    /// `timeout off` — no limit for this step.
    Off,
    /// `timeout 300` / `timeout "10m"` — kill the command after this long.
    After(Duration),
}

impl Timeout {
    /// The limit as a duration, or `None` when the step may run unbounded.
    pub fn limit(self) -> Option<Duration> {
        match self {
            Timeout::Off => None,
            Timeout::After(d) => Some(d),
        }
    }

    /// Render back to `.flux` source text (used by `flux format`).
    pub fn describe(self) -> String {
        match self {
            Timeout::Off => "off".to_string(),
            Timeout::After(d) => format!("\"{}\"", format_duration(d)),
        }
    }
}

/// Render a whole-second duration in the largest unit that divides it exactly,
/// so a parsed `"10m"` formats back to `"10m"` rather than `"600s"`.
pub fn format_duration(d: Duration) -> String {
    let secs = d.as_secs();
    if secs > 0 && secs % 3600 == 0 {
        format!("{}h", secs / 3600)
    } else if secs > 0 && secs % 60 == 0 {
        format!("{}m", secs / 60)
    } else {
        format!("{secs}s")
    }
}

/// An organization-wide policy that a pipeline must satisfy (Phase 4, 4.15).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Policy {
    /// Policy name, e.g. `production`.
    pub name: String,
    /// Require a test step in the pipeline.
    pub require_tests: bool,
    /// Require a security step (a `security`-named step or a `tool` hook).
    pub require_security: bool,
    /// Require at least this many approvals.
    pub require_approvals: u32,
}

/// A pool of runners with shared requirements (Phase 3, 3.1).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RunnerPool {
    /// Pool name, e.g. `gpu-builders`.
    pub name: String,
    /// Requires a GPU.
    pub gpu: Option<bool>,
    /// Minimum memory, as written (e.g. `32gb`).
    pub memory: Option<String>,
    /// Required OS (e.g. `linux`).
    pub os: Option<String>,
}

/// A build environment — a container image the pipeline runs inside.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Environment {
    /// The OCI image, e.g. `rust:latest`.
    pub image: Option<String>,
}

/// A deployment declaration.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Deployment {
    /// Target: `local`, `docker`, `kubernetes`, `vm`, …
    pub target: Option<String>,
    /// Desired replica count (where meaningful).
    pub replicas: Option<u32>,
    /// Optional image to deploy.
    pub image: Option<String>,
}

/// A single pipeline step.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Step {
    /// Step identifier, e.g. `build` or `test`.
    pub name: String,
    /// The shell command to execute, if this is a command step.
    pub command: Option<String>,
    /// An external tool hook (e.g. `tool scanner`) instead of a raw command.
    pub tool: Option<String>,
    /// Optional human description.
    pub description: Option<String>,
    /// Whether this step participates in the build cache. Defaults to `true`.
    pub cache: bool,
    /// Names of steps that must succeed before this one runs (`needs [...]`).
    pub needs: Vec<String>,
    /// A guard: the step only runs when this condition holds (`only_if`).
    pub only_if: Option<Condition>,
    /// How many times to retry the command on failure (default 0).
    pub retries: u32,
    /// Wall-clock limit for one attempt of this step's command. `None` inherits
    /// the pipeline's `timeout`, which itself falls back to the engine default.
    pub timeout: Option<Timeout>,
    /// Declared secret names to decrypt and inject into the command's
    /// environment (`secrets [ DATABASE_URL ]`).
    pub secrets: Vec<String>,
    /// Glob patterns scoping this step's cache inputs (`inputs [ "src/**" ]`).
    /// When empty, the whole project is hashed (Phase 2 behaviour).
    pub inputs: Vec<String>,
}

impl Step {
    /// Create a command-less step with cache enabled by default.
    pub fn new(name: impl Into<String>) -> Self {
        Step {
            name: name.into(),
            command: None,
            tool: None,
            description: None,
            cache: true,
            needs: Vec::new(),
            only_if: None,
            retries: 0,
            timeout: None,
            secrets: Vec::new(),
            inputs: Vec::new(),
        }
    }

    /// Convenience constructor for a shell-command step.
    pub fn command(name: impl Into<String>, command: impl Into<String>) -> Self {
        let mut s = Step::new(name);
        s.command = Some(command.into());
        s
    }

    /// `true` when this step delegates to an external tool rather than a shell
    /// command (an external scanner, linter, or other plugin hook).
    pub fn is_hook(&self) -> bool {
        self.tool.is_some()
    }
}

/// The complete set of variables an `only_if` condition may name, in the order
/// the reference documents them.
///
/// This list is a stability promise, not an implementation detail. *Adding* a
/// variable is backward-compatible (a file that used to be a parse error starts
/// working); *removing* one silently changes which steps run, so the set is
/// settled here rather than left to grow ad hoc. Anything outside it is a parse
/// error, which is why a typo like `brunch` fails loudly instead of comparing
/// against the empty string forever.
///
/// [`crate::core::graph::build_vars`] binds exactly these names on every run.
pub const CONDITION_VARS: &[&str] = &["branch", "tag", "flux_env"];

/// A comparison operator used in `only_if` conditions.
///
/// These two are the whole operator set. There is deliberately no `&&`, `||`,
/// `<`, glob, or regex form: a step guard is a single equality test, and a
/// pipeline that needs more expressive logic should branch inside its command.
/// Widening this later stays compatible; narrowing it would not.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CondOp {
    Eq,
    Ne,
}

/// A single `only_if` condition, e.g. `branch == "main"`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Condition {
    /// The left-hand variable name, always one of [`CONDITION_VARS`].
    pub var: String,
    /// The comparison operator.
    pub op: CondOp,
    /// The right-hand string literal.
    pub value: String,
}

impl Condition {
    /// Evaluate this condition against a set of variable bindings. An unbound
    /// variable compares as the empty string. The parser rejects names outside
    /// [`CONDITION_VARS`], so this only covers a binding the machine could not
    /// determine (say `tag` on a commit that carries none).
    pub fn evaluate(&self, vars: &HashMap<String, String>) -> bool {
        let lhs = vars.get(&self.var).map(String::as_str).unwrap_or("");
        match self.op {
            CondOp::Eq => lhs == self.value,
            CondOp::Ne => lhs != self.value,
        }
    }

    /// A human rendering, e.g. `branch == "main"`.
    pub fn describe(&self) -> String {
        let op = match self.op {
            CondOp::Eq => "==",
            CondOp::Ne => "!=",
        };
        format!("{} {} \"{}\"", self.var, op, self.value)
    }
}