aion-server 0.9.0

Aion workflow server library: HTTP, gRPC, WebSocket, and worker endpoints. Run it with the `aion` binary from the aion-cli crate.
Documentation
use std::path::Path;

use aion_awl::{Span, parse, print, semantic};
use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize)]
pub struct CheckRequest {
    pub source: String,
    pub path: Option<String>,
}

#[derive(Debug, Serialize)]
pub struct CheckResponse {
    pub ok: bool,
    pub deploys_green: bool,
    pub steps: Option<usize>,
    pub diagnostics: Vec<Diagnostic>,
    pub semantic: Option<SemanticIndex>,
}

#[derive(Debug, Clone, Serialize)]
pub struct Diagnostic {
    pub class: DiagnosticClass,
    pub message: String,
    pub line: usize,
    pub column: usize,
}

#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DiagnosticClass {
    Error,
}

#[derive(Debug, Deserialize)]
pub struct FormatRequest {
    pub source: String,
}

#[derive(Debug, Serialize)]
pub struct FormatResponse {
    pub formatted: String,
}

#[derive(Debug, Serialize)]
pub struct SemanticIndex {
    pub entries: Vec<SemanticEntry>,
    pub graph: super::projection::GraphProjection,
    pub studio: super::studio_projection::StudioProjection,
}

#[derive(Debug, Serialize)]
pub struct SemanticEntry {
    pub span: SourceSpan,
    #[serde(rename = "type")]
    pub type_text: Option<String>,
    pub declaration: Option<SemanticDeclaration>,
}

#[derive(Debug, Serialize)]
pub struct SemanticDeclaration {
    pub name: String,
    pub kind: String,
    pub documentation: Option<String>,
    pub span: SourceSpan,
}

#[derive(Debug, Clone, Copy, Serialize)]
pub struct SourceSpan {
    pub start: usize,
    pub end: usize,
    pub line: usize,
    pub column: usize,
}

pub fn check_source(request: &CheckRequest) -> CheckResponse {
    let document = match parse(&request.source) {
        Ok(document) => document,
        Err(error) => {
            return CheckResponse {
                ok: false,
                deploys_green: false,
                steps: None,
                diagnostics: vec![diagnostic(
                    DiagnosticClass::Error,
                    error.message,
                    error.span,
                )],
                semantic: None,
            };
        }
    };
    let root = request
        .path
        .as_deref()
        .and_then(|path| Path::new(path).parent())
        .filter(|path| !path.as_os_str().is_empty());
    let analysis = root.map_or_else(
        || semantic::analyze(&document),
        |root| semantic::analyze_in(&document, root),
    );
    let diagnostics: Vec<_> = analysis
        .diagnostics()
        .iter()
        .map(|error| diagnostic(DiagnosticClass::Error, error.message.clone(), error.span))
        .collect();
    if !diagnostics.is_empty() {
        return CheckResponse {
            ok: false,
            deploys_green: false,
            steps: None,
            diagnostics,
            semantic: None,
        };
    }
    let graph = super::projection::build(&document, analysis.step_kinds());
    let studio = super::studio_projection::build(&document);
    let semantic = SemanticIndex {
        entries: analysis
            .iter()
            .map(|info| SemanticEntry {
                span: info.span.into(),
                type_text: info.ty.clone(),
                declaration: info
                    .declaration
                    .as_ref()
                    .map(|declaration| SemanticDeclaration {
                        name: declaration.name.clone(),
                        kind: declaration.kind.as_str().to_owned(),
                        documentation: declaration.documentation.clone(),
                        span: declaration.span.into(),
                    }),
            })
            .collect(),
        graph,
        studio,
    };
    CheckResponse {
        ok: true,
        deploys_green: diagnostics.is_empty(),
        steps: Some(document.steps.len()),
        diagnostics,
        semantic: Some(semantic),
    }
}

pub fn format_source(request: &FormatRequest) -> Result<FormatResponse, Diagnostic> {
    parse(&request.source)
        .map(|document| FormatResponse {
            formatted: print(&document),
        })
        .map_err(|error| diagnostic(DiagnosticClass::Error, error.message, error.span))
}

fn diagnostic(class: DiagnosticClass, message: String, span: Span) -> Diagnostic {
    Diagnostic {
        class,
        message,
        line: span.line,
        column: span.column,
    }
}

impl From<Span> for SourceSpan {
    fn from(span: Span) -> Self {
        Self {
            start: span.start,
            end: span.end,
            line: span.line,
            column: span.column,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const HAPPY: &str =
        include_str!("../../../aion-awl/tests/fixtures/rev2/dag-fork/valid/after_single.awl");
    const INVALID: &str = include_str!(
        "../../../aion-awl/tests/fixtures/rev2/dag-fork/invalid/unknown_after_target.awl"
    );
    const EMIT_REFUSED: &str = "//! Emitter refusal probe.\nworkflow emit_refused\n  outcome done: type Result, route success\n\ntype Result { value: String }\n\nworker work\n  action make() -> Result\n\nstep finish\n  make() -> result\n  route done(value: result.value)\n  on failure\n    route done(value: \"failed\")\n";

    #[test]
    fn check_happy_path_returns_steps_and_semantics() {
        let response = check_source(&CheckRequest {
            source: HAPPY.to_owned(),
            path: None,
        });
        assert!(response.ok);
        assert!(response.deploys_green);
        assert_eq!(response.steps, Some(2));
        assert!(response.diagnostics.is_empty());
        assert!(
            response
                .semantic
                .is_some_and(|semantic| !semantic.entries.is_empty())
        );
    }

    #[test]
    fn checker_message_surfaces_verbatim() -> Result<(), Box<dyn std::error::Error>> {
        let document = parse(INVALID)?;
        let expected = aion_awl::check(&document)
            .first()
            .ok_or("fixture unexpectedly checks cleanly")?
            .message
            .clone();
        let response = check_source(&CheckRequest {
            source: INVALID.to_owned(),
            path: None,
        });
        assert!(!response.ok);
        assert_eq!(response.diagnostics[0].message, expected);
        assert!(matches!(
            response.diagnostics[0].class,
            DiagnosticClass::Error
        ));
        Ok(())
    }

    #[test]
    fn checker_only_check_does_not_consult_the_legacy_emitter() {
        let response = check_source(&CheckRequest {
            source: EMIT_REFUSED.to_owned(),
            path: None,
        });
        assert!(response.ok, "diagnostics: {:?}", response.diagnostics);
        assert!(response.deploys_green);
        assert!(response.diagnostics.is_empty());
    }

    #[test]
    fn format_is_canonical_and_idempotent() -> Result<(), Diagnostic> {
        let once = format_source(&FormatRequest {
            source: HAPPY.replace("type Summary  {", "type Summary {"),
        })?;
        let twice = format_source(&FormatRequest {
            source: once.formatted.clone(),
        })?;
        assert_eq!(once.formatted, twice.formatted);
        assert_eq!(once.formatted, HAPPY);
        Ok(())
    }

    #[test]
    fn every_valid_fixture_projects_without_mutating_source()
    -> Result<(), Box<dyn std::error::Error>> {
        let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("../aion-awl/tests/fixtures");
        let mut paths = Vec::new();
        collect_valid_fixtures(&fixtures, &mut paths)?;
        assert!(!paths.is_empty());
        for path in paths {
            let source = std::fs::read_to_string(&path)?;
            let original = source.clone();
            let document = parse(&source)
                .map_err(|error| format!("{} did not parse: {}", path.display(), error.message))?;
            let canonical = print(&document);
            assert_eq!(print(&parse(&canonical)?), canonical, "{}", path.display());
            let response = check_source(&CheckRequest {
                source: source.clone(),
                path: Some(path.to_string_lossy().into_owned()),
            });
            assert_eq!(source, original, "projection mutated {}", path.display());
            assert!(
                response.ok,
                "{}: {:?}",
                path.display(),
                response.diagnostics
            );
            let graph = response
                .semantic
                .ok_or("valid fixture had no semantics")?
                .graph;
            assert_eq!(
                graph.steps.len(),
                document.steps.len(),
                "{}",
                path.display()
            );
        }
        Ok(())
    }

    fn collect_valid_fixtures(
        directory: &Path,
        found: &mut Vec<std::path::PathBuf>,
    ) -> std::io::Result<()> {
        for entry in std::fs::read_dir(directory)? {
            let entry = entry?;
            let path = entry.path();
            if path.is_dir() {
                collect_valid_fixtures(&path, found)?;
            } else if path.extension().is_some_and(|extension| extension == "awl")
                && path
                    .components()
                    .any(|component| component.as_os_str() == "valid")
            {
                found.push(path);
            }
        }
        Ok(())
    }
}