crabenv 0.0.4

The simplest, opinionated way to keep .env files, schemas, and examples aligned.
use super::*;
use crate::models::{EnvRecord, Workspace, WorkspaceKind};
use std::collections::BTreeSet;

fn project(root: &Path) -> Project {
    Project {
        root: root.to_path_buf(),
        is_monorepo: true,
        workspaces: vec![Workspace {
            root: root.join("apps/web"),
            rel: PathBuf::from("apps/web"),
            kind: WorkspaceKind::App,
            framework: "typescript".to_string(),
        }],
    }
}

fn graph() -> EnvGraph {
    let mut graph = EnvGraph::new();
    insert_record(&mut graph, "NEXT_PUBLIC_GOOGLE_MAPS_API_KEY", Scope::Public);
    insert_record(&mut graph, "NEXT_PUBLIC_BASE_ORIGIN", Scope::Public);
    insert_record(&mut graph, "AUTH_SECRET", Scope::Private);
    graph
}

fn insert_record(graph: &mut EnvGraph, name: &str, scope: Scope) {
    let owner = PathBuf::from("apps/web");
    let mut surfaces = BTreeSet::new();
    surfaces.insert(EnvSurface::Schema);
    graph.insert(
        (owner.clone(), name.to_string()),
        EnvRecord {
            name: name.to_string(),
            owner,
            scope,
            value_type: None,
            enum_values: None,
            required: None,
            default_value: None,
            description: None,
            example_value: None,
            local_present: false,
            surfaces,
            surface_sources: BTreeMap::new(),
            sources: BTreeSet::new(),
        },
    );
}

#[test]
fn renders_public_gha_env_block_with_implicit_vars() {
    let dir = tempfile::tempdir().unwrap();
    let project = project(dir.path());
    let path = dir.path().join(".github/workflows/deploy.yml");
    let contents = r#"jobs:
  build:
    env:
      # crabenv:start format=gha-env scope=public owner=apps/web
      OLD_VALUE: nope
      # crabenv:end
"#;

    let rendered = render_sink_file(&project, &graph(), &path, contents).unwrap();

    assert_eq!(rendered.changed_block_count, 1);
    assert_eq!(
        rendered.contents,
        r#"jobs:
  build:
    env:
      # crabenv:start format=gha-env scope=public owner=apps/web
      NEXT_PUBLIC_BASE_ORIGIN: ${{ vars.NEXT_PUBLIC_BASE_ORIGIN }}
      NEXT_PUBLIC_GOOGLE_MAPS_API_KEY: ${{ vars.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY }}
      # crabenv:end
"#
    );
}

#[test]
fn preserves_manual_gha_env_vars_or_secrets_source_changes() {
    let dir = tempfile::tempdir().unwrap();
    let project = project(dir.path());
    let path = dir.path().join(".github/workflows/deploy.yml");
    let contents = r#"env:
  # crabenv:start format=gha-env scope=public owner=apps/web
  NEXT_PUBLIC_BASE_ORIGIN: ${{ secrets.NEXT_PUBLIC_BASE_ORIGIN }}
  NEXT_PUBLIC_GOOGLE_MAPS_API_KEY: ${{ vars.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY }}
  # crabenv:end
"#;

    let rendered = render_sink_file(&project, &graph(), &path, contents).unwrap();

    assert_eq!(rendered.changed_block_count, 0);
    assert_eq!(rendered.contents, contents);
}

#[test]
fn gha_env_accepts_all_scope_and_defaults_private_to_secrets() {
    let dir = tempfile::tempdir().unwrap();
    let project = project(dir.path());
    let path = dir.path().join(".github/workflows/deploy.yml");
    let contents = r#"env:
  # crabenv:start format=gha-env scope=all owner=apps/web
  # crabenv:end
"#;

    let rendered = render_sink_file(&project, &graph(), &path, contents).unwrap();

    assert_eq!(
        rendered.contents,
        r#"env:
  # crabenv:start format=gha-env scope=all owner=apps/web
  AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
  NEXT_PUBLIC_BASE_ORIGIN: ${{ vars.NEXT_PUBLIC_BASE_ORIGIN }}
  NEXT_PUBLIC_GOOGLE_MAPS_API_KEY: ${{ vars.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY }}
  # crabenv:end
"#
    );
}

#[test]
fn renders_gha_echo_block_to_dotenv_file() {
    let dir = tempfile::tempdir().unwrap();
    let project = project(dir.path());
    let path = dir.path().join(".github/workflows/deploy.yml");
    let contents = r#"steps:
  - name: Create env file
    run: |
      # crabenv:start format=gha-echo scope=all owner=apps/web dest=.env.local
      OLD_VALUE=nope
      # crabenv:end
"#;

    let rendered = render_sink_file(&project, &graph(), &path, contents).unwrap();

    assert_eq!(rendered.changed_block_count, 1);
    assert_eq!(
        rendered.contents,
        r#"steps:
  - name: Create env file
    run: |
      # crabenv:start format=gha-echo scope=all owner=apps/web dest=.env.local
      echo "AUTH_SECRET=${{ secrets.AUTH_SECRET }}" >> .env.local
      echo "NEXT_PUBLIC_BASE_ORIGIN=${{ vars.NEXT_PUBLIC_BASE_ORIGIN }}" >> .env.local
      echo "NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=${{ vars.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY }}" >> .env.local
      # crabenv:end
"#
    );
}

#[test]
fn preserves_manual_gha_echo_vars_or_secrets_source_changes() {
    let dir = tempfile::tempdir().unwrap();
    let project = project(dir.path());
    let path = dir.path().join(".github/workflows/deploy.yml");
    let contents = r#"run: |
  # crabenv:start format=gha-echo scope=all owner=apps/web dest=.env.local
  echo "AUTH_SECRET=${{ vars.AUTH_SECRET }}" >> .env.local
  echo "NEXT_PUBLIC_BASE_ORIGIN=${{ secrets.NEXT_PUBLIC_BASE_ORIGIN }}" >> .env.local
  echo "NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=${{ vars.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY }}" >> .env.local
  # crabenv:end
"#;

    let rendered = render_sink_file(&project, &graph(), &path, contents).unwrap();

    assert_eq!(rendered.changed_block_count, 0);
    assert_eq!(rendered.contents, contents);
}

#[test]
fn rejects_missing_dest_for_gha_echo() {
    let dir = tempfile::tempdir().unwrap();
    let project = project(dir.path());
    let path = dir.path().join(".github/workflows/deploy.yml");
    let contents = r#"run: |
  # crabenv:start format=gha-echo scope=all owner=apps/web
  # crabenv:end
"#;

    let error = render_sink_file(&project, &graph(), &path, contents).unwrap_err();

    assert!(error
        .to_string()
        .contains("missing required crabenv sink option `dest`"));
}

#[test]
fn collects_sink_surface_sources_from_managed_blocks() {
    let dir = tempfile::tempdir().unwrap();
    let project = project(dir.path());
    let path = dir.path().join(".github/workflows/deploy.yml");
    let contents = r#"jobs:
  build:
    env:
      # crabenv:start format=gha-env scope=public owner=apps/web
      # crabenv:end
    steps:
      - run: |
          # crabenv:start format=gha-echo scope=private owner=apps/web dest=.env.local
          # crabenv:end
"#;

    let sources = collect_sink_file_surface_sources(&project, &graph(), &path, contents).unwrap();
    let names = sources
        .iter()
        .map(|source| source.name.as_str())
        .collect::<Vec<_>>();

    assert_eq!(
        names,
        vec![
            "NEXT_PUBLIC_BASE_ORIGIN",
            "NEXT_PUBLIC_GOOGLE_MAPS_API_KEY",
            "AUTH_SECRET",
        ]
    );
    assert!(sources
        .iter()
        .all(|source| source.owner == PathBuf::from("apps/web")));
    assert!(sources[0].location.ends_with(":4"));
    assert!(sources[2].location.ends_with(":8"));
}

#[test]
fn rejects_source_option_for_gha_env() {
    let dir = tempfile::tempdir().unwrap();
    let project = project(dir.path());
    let path = dir.path().join(".github/workflows/deploy.yml");
    let contents = r#"env:
  # crabenv:start format=gha-env scope=public owner=apps/web source=secrets
  # crabenv:end
"#;

    let error = render_sink_file(&project, &graph(), &path, contents).unwrap_err();

    assert!(error
        .to_string()
        .contains("unsupported crabenv sink option `source`"));
}