floe-cli 0.3.5

CLI for Floe, a YAML-driven technical ingestion tool.
#[path = "../src/output.rs"]
mod output;

use floe_core::{report, DryRunEntityPreview, EntityOutcome, RunOutcome};

fn sample_outcome() -> RunOutcome {
    let run_id = "run-123".to_string();
    let report_base_path = "/tmp/reports".to_string();
    let report_file = "/tmp/reports/run_run-123/customer/run.json".to_string();
    let version = env!("CARGO_PKG_VERSION").to_string();

    let report = report::RunReport {
        spec_version: version.clone(),
        entity: report::EntityEcho {
            name: "customer".to_string(),
            metadata: None,
        },
        source: report::SourceEcho {
            format: "csv".to_string(),
            path: "/tmp/input".to_string(),
            options: None,
            cast_mode: None,
            read_plan: report::SourceReadPlan::RawAndTyped,
            resolved_inputs: report::ResolvedInputs {
                mode: report::ResolvedInputMode::Directory,
                file_count: 1,
                files: vec!["/tmp/input/customers.csv".to_string()],
            },
        },
        sink: report::SinkEcho {
            accepted: report::SinkTargetEcho {
                format: "parquet".to_string(),
                path: "/tmp/out/accepted".to_string(),
            },
            rejected: Some(report::SinkTargetEcho {
                format: "csv".to_string(),
                path: "/tmp/out/rejected".to_string(),
            }),
            archive: report::SinkArchiveEcho {
                enabled: false,
                path: None,
            },
        },
        policy: report::PolicyEcho {
            severity: report::Severity::Reject,
        },
        accepted_output: report::AcceptedOutputSummary {
            path: "/tmp/out/accepted".to_string(),
            table_root_uri: Some("/tmp/out/accepted".to_string()),
            write_mode: Some("overwrite".to_string()),
            accepted_rows: 8,
            files_written: Some(1),
            parts_written: 1,
            part_files: vec!["part-00000.parquet".to_string()],
            table_version: None,
            snapshot_id: None,
            iceberg_catalog_name: None,
            iceberg_database: None,
            iceberg_namespace: None,
            iceberg_table: None,
            total_bytes_written: None,
            avg_file_size_mb: None,
            small_files_count: None,
            merge_key: Vec::new(),
            inserted_count: None,
            updated_count: None,
            closed_count: None,
            unchanged_count: None,
            target_rows_before: None,
            target_rows_after: None,
            merge_elapsed_ms: None,
        },
        schema_evolution: report::SchemaEvolutionSummary {
            enabled: false,
            mode: "strict".to_string(),
            applied: false,
            added_columns: Vec::new(),
            incompatible_changes_detected: false,
        },
        unique_constraints: Vec::new(),
        results: report::ResultsTotals {
            files_total: 1,
            rows_total: 10,
            accepted_total: 8,
            rejected_total: 2,
            warnings_total: 0,
            errors_total: 0,
        },
        files: vec![report::FileReport {
            input_file: "/tmp/input/customers.csv".to_string(),
            status: report::FileStatus::Rejected,
            row_count: 10,
            accepted_count: 8,
            rejected_count: 2,
            mismatch: report::FileMismatch {
                declared_columns_count: 2,
                input_columns_count: 2,
                missing_columns: Vec::new(),
                extra_columns: Vec::new(),
                mismatch_action: report::MismatchAction::None,
                error: None,
                warning: None,
            },
            output: report::FileOutput {
                accepted_path: Some("/tmp/out/accepted".to_string()),
                rejected_path: Some("/tmp/out/rejected/customers.rejected.csv".to_string()),
                errors_path: None,
                archived_path: None,
            },
            validation: report::FileValidation {
                errors: 1,
                warnings: 0,
                rules: Vec::new(),
            },
        }],
    };

    let summary = report::RunSummaryReport {
        spec_version: version.clone(),
        tool: report::ToolInfo {
            name: "floe".to_string(),
            version: version.clone(),
            git: None,
        },
        run: report::RunInfo {
            run_id: run_id.clone(),
            started_at: "2026-01-19T10-23-45Z".to_string(),
            finished_at: "2026-01-19T10-23-46Z".to_string(),
            duration_ms: 1000,
            status: report::RunStatus::Rejected,
            exit_code: 0,
        },
        config: report::ConfigEcho {
            path: "/tmp/config.yml".to_string(),
            version,
            metadata: None,
        },
        report: report::ReportEcho {
            path: report_base_path.clone(),
            report_file,
        },
        results: report::ResultsTotals {
            files_total: 1,
            rows_total: 10,
            accepted_total: 8,
            rejected_total: 2,
            warnings_total: 0,
            errors_total: 0,
        },
        entities: vec![report::EntitySummary {
            name: "customer".to_string(),
            status: report::RunStatus::Rejected,
            results: report::ResultsTotals {
                files_total: 1,
                rows_total: 10,
                accepted_total: 8,
                rejected_total: 2,
                warnings_total: 0,
                errors_total: 0,
            },
            report_file: "/tmp/reports/run_run-123/customer/run.json".to_string(),
        }],
    };

    RunOutcome {
        run_id,
        report_base_path: Some(report_base_path),
        entity_outcomes: vec![EntityOutcome {
            report,
            file_timings_ms: vec![Some(12)],
        }],
        summary,
        dry_run_previews: None,
    }
}

fn sample_dry_run_outcome() -> RunOutcome {
    let run_id = "run-456".to_string();
    let report_base_path = "/tmp/reports-dry".to_string();
    let version = env!("CARGO_PKG_VERSION").to_string();

    let summary = report::RunSummaryReport {
        spec_version: version.clone(),
        tool: report::ToolInfo {
            name: "floe".to_string(),
            version: version.clone(),
            git: None,
        },
        run: report::RunInfo {
            run_id: run_id.clone(),
            started_at: "2026-01-19T11-00-00Z".to_string(),
            finished_at: "2026-01-19T11-00-01Z".to_string(),
            duration_ms: 0,
            status: report::RunStatus::Success,
            exit_code: 0,
        },
        config: report::ConfigEcho {
            path: "/tmp/config.yml".to_string(),
            version,
            metadata: None,
        },
        report: report::ReportEcho {
            path: report_base_path.clone(),
            report_file: "disabled (dry-run)".to_string(),
        },
        results: report::ResultsTotals {
            files_total: 0,
            rows_total: 0,
            accepted_total: 0,
            rejected_total: 0,
            warnings_total: 0,
            errors_total: 0,
        },
        entities: Vec::new(),
    };

    let previews = vec![DryRunEntityPreview {
        name: "orders".to_string(),
        input_path: "/tmp/in/orders".to_string(),
        input_format: "json".to_string(),
        accepted_path: "/tmp/out/orders.parquet".to_string(),
        accepted_format: "parquet".to_string(),
        rejected_path: Some("/tmp/out/orders.rejected".to_string()),
        rejected_format: Some("csv".to_string()),
        archive_path: "/tmp/archive/orders".to_string(),
        archive_storage: Some("s3".to_string()),
        report_file: Some("/tmp/reports-dry/run_run-456/orders/run.json".to_string()),
        scanned_files: vec!["/tmp/in/orders/2026-01-01.json".to_string()],
    }];

    RunOutcome {
        run_id,
        report_base_path: Some(report_base_path),
        entity_outcomes: Vec::new(),
        summary,
        dry_run_previews: Some(previews),
    }
}

#[test]
fn format_run_output_default_mode() {
    let outcome = sample_outcome();
    let output = output::format_run_output(&outcome, output::OutputMode::Default, false);
    let expected = [
        "run id: run-123",
        "report base: /tmp/reports",
        "",
        "==> entity customer (severity=reject, format=csv)",
        "  REJECTED customers.csv rows=10 accepted=8 rejected=2 elapsed_ms=12 accepted_out=accepted rejected_out=customers.rejected.csv",
        "",
        "Totals: files=1 rows=10 accepted=8 rejected=2",
        "Overall: rejected (exit_code=0)",
        "Run summary: /tmp/reports/run_run-123/run.summary.json",
    ]
    .join("\n");

    assert_eq!(output, expected);
}

#[test]
fn format_run_output_dry_run_default_mode() {
    let outcome = sample_dry_run_outcome();
    let output = output::format_run_output(&outcome, output::OutputMode::Default, true);
    let expected = [
        "DRY RUN MODE - No actual execution performed",
        "run id: run-456",
        "report base: /tmp/reports-dry",
        "",
        "==> entity orders (format=json)",
        "  Input: /tmp/in/orders (json)",
        "  Accepted Output: /tmp/out/orders.parquet (parquet)",
        "  Rejected Output: /tmp/out/orders.rejected (csv)",
        "  Archive Path: /tmp/archive/orders (s3)",
        "  Resolved Inputs: 1",
        "  Resolved Files:",
        "    /tmp/in/orders/2026-01-01.json",
        "",
        "Totals: files=0 rows=0 accepted=0 rejected=0",
        "Overall: success (exit_code=0)",
        "Run summary: /tmp/reports-dry/run_run-456/run.summary.json",
    ]
    .join("\n");

    assert_eq!(output, expected);
}

#[test]
fn format_run_output_dry_run_verbose_mode() {
    let outcome = sample_dry_run_outcome();
    let output = output::format_run_output(&outcome, output::OutputMode::Verbose, true);
    let expected = [
        "DRY RUN MODE - No actual execution performed",
        "run id: run-456",
        "report base: /tmp/reports-dry",
        "",
        "==> entity orders (format=json)",
        "  Input: /tmp/in/orders (json)",
        "  Accepted Output: /tmp/out/orders.parquet (parquet)",
        "  Rejected Output: /tmp/out/orders.rejected (csv)",
        "  Archive Path: /tmp/archive/orders (s3)",
        "  Resolved Inputs: 1",
        "  Resolved Files:",
        "    /tmp/in/orders/2026-01-01.json",
        "  report: /tmp/reports-dry/run_run-456/orders/run.json",
        "",
        "Totals: files=0 rows=0 accepted=0 rejected=0",
        "Overall: success (exit_code=0)",
        "Run summary: /tmp/reports-dry/run_run-456/run.summary.json",
    ]
    .join("\n");

    assert_eq!(output, expected);
}