Skip to main content

cobre_io/validation/
structural.rs

1//! Layer 1 — Structural validation.
2//!
3//! Checks that required files exist in the case directory and records whether
4//! optional files are present.  This layer does **not** parse any file content;
5//! it only tests for the existence of paths on disk. It also rejects any file
6//! whose input contract has been withdrawn but is still present
7//! ([`ErrorKind::BusinessRuleViolation`]).
8//!
9//! Call [`validate_structure`] with a path to the case root and a mutable
10//! [`ValidationContext`].  It returns a [`FileManifest`] with one field per
11//! `FILE_ENTRIES` row, zipped positionally.  Missing required files produce
12//! [`ErrorKind::FileNotFound`] entries in the context.  Missing optional files
13//! leave the corresponding manifest field `false` without adding any error.
14//!
15//! # Examples
16//!
17//! ```no_run
18//! use std::path::Path;
19//! use cobre_io::validation::{ValidationContext, structural::validate_structure};
20//!
21//! let mut ctx = ValidationContext::new();
22//! let manifest = validate_structure(Path::new("/path/to/case"), &mut ctx);
23//! assert!(!ctx.has_errors());
24//! assert!(manifest.config_json);
25//! ```
26
27use std::path::Path;
28
29use super::{ErrorKind, ValidationContext};
30
31// ── FileManifest ─────────────────────────────────────────────────────────────
32
33/// Records whether each input file (one field per `FILE_ENTRIES` row) is
34/// present in the case directory.
35///
36/// Fields default to `false`; [`validate_structure`] sets each to `true` if the
37/// corresponding file was found on disk.
38#[allow(clippy::struct_excessive_bools)]
39#[derive(Debug, Clone, Default)]
40pub struct FileManifest {
41    /// `config.json` — required
42    pub config_json: bool,
43    /// `penalties.json` — required
44    pub penalties_json: bool,
45    /// `stages.json` — required
46    pub stages_json: bool,
47    /// `initial_conditions.json` — required
48    pub initial_conditions_json: bool,
49    /// `post_study_stages.json` — optional
50    pub post_study_stages_json: bool,
51
52    /// `system/buses.json` — required
53    pub system_buses_json: bool,
54    /// `system/lines.json` — required
55    pub system_lines_json: bool,
56    /// `system/hydros.json` — required
57    pub system_hydros_json: bool,
58    /// `system/thermals.json` — required
59    pub system_thermals_json: bool,
60    /// `system/non_controllable_sources.json` — optional
61    pub system_non_controllable_sources_json: bool,
62    /// `system/pumping_stations.json` — optional
63    pub system_pumping_stations_json: bool,
64    /// `system/energy_contracts.json` — optional
65    pub system_energy_contracts_json: bool,
66    /// `system/hydro_geometry.parquet` — optional
67    pub system_hydro_geometry_parquet: bool,
68    /// `system/hydro_production_models.json` — optional
69    pub system_hydro_production_models_json: bool,
70    /// `system/fpha_hyperplanes.parquet` — optional
71    pub system_fpha_hyperplanes_parquet: bool,
72    /// `system/hydro_energy_productivity.parquet` — optional
73    pub system_hydro_energy_productivity_parquet: bool,
74    /// `system/tailrace_curves.parquet` — optional
75    pub system_tailrace_curves_parquet: bool,
76
77    /// `scenarios/inflow_history.parquet` — optional
78    pub scenarios_inflow_history_parquet: bool,
79    /// `scenarios/inflow_seasonal_stats.parquet` — optional
80    pub scenarios_inflow_seasonal_stats_parquet: bool,
81    /// `scenarios/inflow_ar_coefficients.parquet` — optional
82    pub scenarios_inflow_ar_coefficients_parquet: bool,
83    /// `scenarios/inflow_annual_component.parquet` — optional
84    pub scenarios_inflow_annual_component_parquet: bool,
85    /// `scenarios/external_inflow_scenarios.parquet` — optional
86    pub scenarios_external_inflow_scenarios_parquet: bool,
87    /// `scenarios/external_load_scenarios.parquet` — optional
88    pub scenarios_external_load_scenarios_parquet: bool,
89    /// `scenarios/external_ncs_scenarios.parquet` — optional
90    pub scenarios_external_ncs_scenarios_parquet: bool,
91    /// `scenarios/load_seasonal_stats.parquet` — optional
92    pub scenarios_load_seasonal_stats_parquet: bool,
93    /// `scenarios/load_factors.json` — optional
94    pub scenarios_load_factors_json: bool,
95    /// `scenarios/correlation.json` — optional
96    pub scenarios_correlation_json: bool,
97    /// `scenarios/non_controllable_factors.json` — optional
98    pub scenarios_non_controllable_factors_json: bool,
99    /// `scenarios/non_controllable_stats.parquet` — optional
100    pub scenarios_non_controllable_stats_parquet: bool,
101
102    /// `constraints/thermal_bounds.parquet` — optional
103    pub constraints_thermal_bounds_parquet: bool,
104    /// `constraints/hydro_bounds.parquet` — optional
105    pub constraints_hydro_bounds_parquet: bool,
106    /// `constraints/line_bounds.parquet` — optional
107    pub constraints_line_bounds_parquet: bool,
108    /// `constraints/pumping_bounds.parquet` — optional
109    pub constraints_pumping_bounds_parquet: bool,
110    /// `constraints/contract_bounds.parquet` — optional
111    pub constraints_contract_bounds_parquet: bool,
112    /// `constraints/generic_constraints.json` — optional
113    pub constraints_generic_constraints_json: bool,
114    /// `constraints/generic_constraint_bounds.parquet` — optional
115    pub constraints_generic_constraint_bounds_parquet: bool,
116    /// `constraints/generic_parameters.json` — optional
117    pub constraints_generic_parameters_json: bool,
118    /// `constraints/penalty_overrides_bus.parquet` — optional
119    pub constraints_penalty_overrides_bus_parquet: bool,
120    /// `constraints/penalty_overrides_line.parquet` — optional
121    pub constraints_penalty_overrides_line_parquet: bool,
122    /// `constraints/penalty_overrides_hydro.parquet` — optional
123    pub constraints_penalty_overrides_hydro_parquet: bool,
124    /// `constraints/penalty_overrides_ncs.parquet` — optional
125    pub constraints_penalty_overrides_ncs_parquet: bool,
126    /// `constraints/ncs_bounds.parquet` — optional
127    pub constraints_ncs_bounds_parquet: bool,
128    /// `constraints/hydro_unit_group_bounds.parquet` — optional
129    pub constraints_hydro_unit_group_bounds_parquet: bool,
130}
131
132// ── validate_structure ────────────────────────────────────────────────────────
133
134/// Describes a single file entry for the structural check.
135struct FileEntry {
136    /// Path relative to the case root.
137    relative: &'static str,
138    required: bool,
139}
140
141/// Every input file in canonical order — one row per [`FileManifest`] field,
142/// zipped positionally by [`manifest_fields_mut`].
143const FILE_ENTRIES: &[FileEntry] = &[
144    // Root-level — required
145    FileEntry {
146        relative: "config.json",
147        required: true,
148    },
149    FileEntry {
150        relative: "penalties.json",
151        required: true,
152    },
153    FileEntry {
154        relative: "stages.json",
155        required: true,
156    },
157    FileEntry {
158        relative: "initial_conditions.json",
159        required: true,
160    },
161    // Root-level — optional
162    FileEntry {
163        relative: "post_study_stages.json",
164        required: false,
165    },
166    // system/ — required
167    FileEntry {
168        relative: "system/buses.json",
169        required: true,
170    },
171    FileEntry {
172        relative: "system/lines.json",
173        required: true,
174    },
175    FileEntry {
176        relative: "system/hydros.json",
177        required: true,
178    },
179    FileEntry {
180        relative: "system/thermals.json",
181        required: true,
182    },
183    // system/ — optional
184    FileEntry {
185        relative: "system/non_controllable_sources.json",
186        required: false,
187    },
188    FileEntry {
189        relative: "system/pumping_stations.json",
190        required: false,
191    },
192    FileEntry {
193        relative: "system/energy_contracts.json",
194        required: false,
195    },
196    FileEntry {
197        relative: "system/hydro_geometry.parquet",
198        required: false,
199    },
200    FileEntry {
201        relative: "system/hydro_production_models.json",
202        required: false,
203    },
204    FileEntry {
205        relative: "system/fpha_hyperplanes.parquet",
206        required: false,
207    },
208    FileEntry {
209        relative: "system/hydro_energy_productivity.parquet",
210        required: false,
211    },
212    FileEntry {
213        relative: "system/tailrace_curves.parquet",
214        required: false,
215    },
216    // scenarios/ — optional
217    FileEntry {
218        relative: "scenarios/inflow_history.parquet",
219        required: false,
220    },
221    FileEntry {
222        relative: "scenarios/inflow_seasonal_stats.parquet",
223        required: false,
224    },
225    FileEntry {
226        relative: "scenarios/inflow_ar_coefficients.parquet",
227        required: false,
228    },
229    FileEntry {
230        relative: "scenarios/inflow_annual_component.parquet",
231        required: false,
232    },
233    FileEntry {
234        relative: "scenarios/external_inflow_scenarios.parquet",
235        required: false,
236    },
237    FileEntry {
238        relative: "scenarios/external_load_scenarios.parquet",
239        required: false,
240    },
241    FileEntry {
242        relative: "scenarios/external_ncs_scenarios.parquet",
243        required: false,
244    },
245    FileEntry {
246        relative: "scenarios/load_seasonal_stats.parquet",
247        required: false,
248    },
249    FileEntry {
250        relative: "scenarios/load_factors.json",
251        required: false,
252    },
253    FileEntry {
254        relative: "scenarios/correlation.json",
255        required: false,
256    },
257    FileEntry {
258        relative: "scenarios/non_controllable_factors.json",
259        required: false,
260    },
261    FileEntry {
262        relative: "scenarios/non_controllable_stats.parquet",
263        required: false,
264    },
265    // constraints/ — optional
266    FileEntry {
267        relative: "constraints/thermal_bounds.parquet",
268        required: false,
269    },
270    FileEntry {
271        relative: "constraints/hydro_bounds.parquet",
272        required: false,
273    },
274    FileEntry {
275        relative: "constraints/line_bounds.parquet",
276        required: false,
277    },
278    FileEntry {
279        relative: "constraints/pumping_bounds.parquet",
280        required: false,
281    },
282    FileEntry {
283        relative: "constraints/contract_bounds.parquet",
284        required: false,
285    },
286    FileEntry {
287        relative: "constraints/generic_constraints.json",
288        required: false,
289    },
290    FileEntry {
291        relative: "constraints/generic_constraint_bounds.parquet",
292        required: false,
293    },
294    FileEntry {
295        relative: "constraints/generic_parameters.json",
296        required: false,
297    },
298    FileEntry {
299        relative: "constraints/penalty_overrides_bus.parquet",
300        required: false,
301    },
302    FileEntry {
303        relative: "constraints/penalty_overrides_line.parquet",
304        required: false,
305    },
306    FileEntry {
307        relative: "constraints/penalty_overrides_hydro.parquet",
308        required: false,
309    },
310    FileEntry {
311        relative: "constraints/penalty_overrides_ncs.parquet",
312        required: false,
313    },
314    FileEntry {
315        relative: "constraints/ncs_bounds.parquet",
316        required: false,
317    },
318    FileEntry {
319        relative: "constraints/hydro_unit_group_bounds.parquet",
320        required: false,
321    },
322];
323
324/// Describes an input file no longer read; `replacement` names its migration
325/// in the rejection message.
326struct RemovedFile {
327    /// Path relative to the case root.
328    relative: &'static str,
329    replacement: &'static str,
330}
331
332/// An input file no longer read by any loader is rejected when present —
333/// never accepted and silently ignored.
334const REMOVED_FILES: &[RemovedFile] = &[
335    RemovedFile {
336        relative: "constraints/exchange_factors.json",
337        replacement: "per-block line capacity is now declared as absolute MW in \
338            constraints/line_bounds.parquet via direct_mw / reverse_mw rows \
339            carrying a block_id (use base_capacity * factor for each block). \
340            Remove the file.",
341    },
342    RemovedFile {
343        relative: "system/scalar_parameters.json",
344        replacement: "scalar parameters are now read from \
345            constraints/generic_parameters.json, beside the constraints that \
346            reference them by @name. Move the file (its contents are unchanged).",
347    },
348];
349
350/// Performs Layer 1 structural validation on the case directory at `case_root`,
351/// returning a [`FileManifest`] of which files are present.
352///
353/// A present file sets its manifest field to `true`. An absent **required** file
354/// adds an [`ErrorKind::FileNotFound`] error; an absent **optional** file leaves
355/// its field `false` with no error. A present file listed in `REMOVED_FILES`
356/// adds an [`ErrorKind::BusinessRuleViolation`] error naming its replacement.
357/// This function does **not** read or parse any file content.
358#[must_use]
359pub fn validate_structure(case_root: &Path, ctx: &mut ValidationContext) -> FileManifest {
360    let mut manifest = FileManifest::default();
361
362    for removed in REMOVED_FILES {
363        if case_root.join(removed.relative).exists() {
364            ctx.add_error(
365                ErrorKind::BusinessRuleViolation,
366                removed.relative,
367                None::<&str>,
368                format!(
369                    "{} is no longer read; {}",
370                    removed.relative, removed.replacement
371                ),
372            );
373        }
374    }
375
376    for (entry, present) in FILE_ENTRIES.iter().zip(manifest_fields_mut(&mut manifest)) {
377        if case_root.join(entry.relative).exists() {
378            *present = true;
379        } else if entry.required {
380            ctx.add_error(
381                ErrorKind::FileNotFound,
382                entry.relative,
383                None::<&str>,
384                format!(
385                    "required file '{}' not found in case directory",
386                    entry.relative
387                ),
388            );
389        }
390    }
391
392    manifest
393}
394
395/// Returns mutable references to every `bool` field of [`FileManifest`] in the
396/// same order as [`FILE_ENTRIES`] — `validate_structure` zips the two positionally,
397/// so a divergence here silently misassigns presence flags.
398fn manifest_fields_mut(m: &mut FileManifest) -> [&mut bool; 43] {
399    [
400        // Root
401        &mut m.config_json,
402        &mut m.penalties_json,
403        &mut m.stages_json,
404        &mut m.initial_conditions_json,
405        &mut m.post_study_stages_json,
406        // system/ required
407        &mut m.system_buses_json,
408        &mut m.system_lines_json,
409        &mut m.system_hydros_json,
410        &mut m.system_thermals_json,
411        // system/ optional
412        &mut m.system_non_controllable_sources_json,
413        &mut m.system_pumping_stations_json,
414        &mut m.system_energy_contracts_json,
415        &mut m.system_hydro_geometry_parquet,
416        &mut m.system_hydro_production_models_json,
417        &mut m.system_fpha_hyperplanes_parquet,
418        &mut m.system_hydro_energy_productivity_parquet,
419        &mut m.system_tailrace_curves_parquet,
420        // scenarios/
421        &mut m.scenarios_inflow_history_parquet,
422        &mut m.scenarios_inflow_seasonal_stats_parquet,
423        &mut m.scenarios_inflow_ar_coefficients_parquet,
424        &mut m.scenarios_inflow_annual_component_parquet,
425        &mut m.scenarios_external_inflow_scenarios_parquet,
426        &mut m.scenarios_external_load_scenarios_parquet,
427        &mut m.scenarios_external_ncs_scenarios_parquet,
428        &mut m.scenarios_load_seasonal_stats_parquet,
429        &mut m.scenarios_load_factors_json,
430        &mut m.scenarios_correlation_json,
431        &mut m.scenarios_non_controllable_factors_json,
432        &mut m.scenarios_non_controllable_stats_parquet,
433        // constraints/
434        &mut m.constraints_thermal_bounds_parquet,
435        &mut m.constraints_hydro_bounds_parquet,
436        &mut m.constraints_line_bounds_parquet,
437        &mut m.constraints_pumping_bounds_parquet,
438        &mut m.constraints_contract_bounds_parquet,
439        &mut m.constraints_generic_constraints_json,
440        &mut m.constraints_generic_constraint_bounds_parquet,
441        &mut m.constraints_generic_parameters_json,
442        &mut m.constraints_penalty_overrides_bus_parquet,
443        &mut m.constraints_penalty_overrides_line_parquet,
444        &mut m.constraints_penalty_overrides_hydro_parquet,
445        &mut m.constraints_penalty_overrides_ncs_parquet,
446        &mut m.constraints_ncs_bounds_parquet,
447        &mut m.constraints_hydro_unit_group_bounds_parquet,
448    ]
449}
450
451// ── Tests ─────────────────────────────────────────────────────────────────────
452
453#[cfg(test)]
454#[allow(clippy::unwrap_used)]
455mod tests {
456    use super::*;
457    use std::fs;
458    use tempfile::TempDir;
459
460    /// Create a temporary case directory containing all 8 required files.
461    fn make_case_with_required(dir: &TempDir) {
462        let root = dir.path();
463        fs::create_dir_all(root.join("system")).unwrap();
464        fs::write(root.join("config.json"), b"{}").unwrap();
465        fs::write(root.join("penalties.json"), b"{}").unwrap();
466        fs::write(root.join("stages.json"), b"{}").unwrap();
467        fs::write(root.join("initial_conditions.json"), b"{}").unwrap();
468        fs::write(root.join("system/buses.json"), b"{}").unwrap();
469        fs::write(root.join("system/lines.json"), b"{}").unwrap();
470        fs::write(root.join("system/hydros.json"), b"{}").unwrap();
471        fs::write(root.join("system/thermals.json"), b"{}").unwrap();
472    }
473
474    #[test]
475    fn test_structural_all_required_present() {
476        let dir = TempDir::new().unwrap();
477        make_case_with_required(&dir);
478
479        let mut ctx = ValidationContext::new();
480        let manifest = validate_structure(dir.path(), &mut ctx);
481
482        assert!(
483            !ctx.has_errors(),
484            "should have 0 errors when all required files present, got: {:?}",
485            ctx.errors()
486        );
487
488        assert!(manifest.config_json, "config.json should be present");
489        assert!(manifest.penalties_json, "penalties.json should be present");
490        assert!(manifest.stages_json, "stages.json should be present");
491        assert!(
492            manifest.initial_conditions_json,
493            "initial_conditions.json should be present"
494        );
495        assert!(
496            manifest.system_buses_json,
497            "system/buses.json should be present"
498        );
499        assert!(
500            manifest.system_lines_json,
501            "system/lines.json should be present"
502        );
503        assert!(
504            manifest.system_hydros_json,
505            "system/hydros.json should be present"
506        );
507        assert!(
508            manifest.system_thermals_json,
509            "system/thermals.json should be present"
510        );
511    }
512
513    #[test]
514    fn test_structural_missing_required_hydros() {
515        let dir = TempDir::new().unwrap();
516        make_case_with_required(&dir);
517        fs::remove_file(dir.path().join("system/hydros.json")).unwrap();
518
519        let mut ctx = ValidationContext::new();
520        let manifest = validate_structure(dir.path(), &mut ctx);
521
522        assert!(
523            ctx.has_errors(),
524            "should have at least 1 error when system/hydros.json is missing"
525        );
526        assert_eq!(ctx.errors().len(), 1, "should have exactly 1 error");
527        let entry = &ctx.errors()[0];
528        assert_eq!(
529            entry.kind,
530            ErrorKind::FileNotFound,
531            "error kind should be FileNotFound"
532        );
533        assert!(
534            entry.file.to_string_lossy().contains("hydros.json"),
535            "error file should reference hydros.json, got: {}",
536            entry.file.display()
537        );
538        assert!(
539            !manifest.system_hydros_json,
540            "manifest.system_hydros_json should be false"
541        );
542    }
543
544    #[test]
545    fn test_structural_optional_absent_no_error() {
546        let dir = TempDir::new().unwrap();
547        make_case_with_required(&dir);
548        // No optional files are created
549
550        let mut ctx = ValidationContext::new();
551        let manifest = validate_structure(dir.path(), &mut ctx);
552
553        assert!(
554            !ctx.has_errors(),
555            "absent optional files should not produce errors"
556        );
557
558        // Verify representative optional files are false
559        assert!(!manifest.system_non_controllable_sources_json);
560        assert!(!manifest.system_hydro_geometry_parquet);
561        assert!(!manifest.scenarios_inflow_history_parquet);
562        assert!(!manifest.constraints_thermal_bounds_parquet);
563        assert!(!manifest.scenarios_correlation_json);
564    }
565
566    #[test]
567    fn test_structural_optional_present_in_manifest() {
568        let dir = TempDir::new().unwrap();
569        make_case_with_required(&dir);
570        let scenarios_dir = dir.path().join("scenarios");
571        fs::create_dir_all(&scenarios_dir).unwrap();
572        fs::write(scenarios_dir.join("correlation.json"), b"{}").unwrap();
573
574        let mut ctx = ValidationContext::new();
575        let manifest = validate_structure(dir.path(), &mut ctx);
576
577        assert!(!ctx.has_errors());
578        assert!(
579            manifest.scenarios_correlation_json,
580            "present optional file should be marked true in manifest"
581        );
582    }
583
584    #[test]
585    fn test_structural_multiple_missing_required() {
586        let dir = TempDir::new().unwrap();
587        // Create only the system/ subdirectory but no files
588        fs::create_dir_all(dir.path().join("system")).unwrap();
589
590        let mut ctx = ValidationContext::new();
591        let _manifest = validate_structure(dir.path(), &mut ctx);
592
593        assert!(
594            ctx.has_errors(),
595            "should have errors for all 8 missing required files"
596        );
597        assert_eq!(
598            ctx.errors().len(),
599            8,
600            "should have exactly 8 errors (one per required file), got: {}",
601            ctx.errors().len()
602        );
603        for entry in ctx.errors() {
604            assert_eq!(
605                entry.kind,
606                ErrorKind::FileNotFound,
607                "all errors should be FileNotFound"
608            );
609        }
610    }
611
612    #[test]
613    fn test_structural_manifest_fields_count() {
614        // validate_structure zips FILE_ENTRIES with manifest_fields_mut positionally;
615        // the invariant is that the two stay the same length, not any specific count.
616        let mut manifest = FileManifest::default();
617        let fields = manifest_fields_mut(&mut manifest);
618        assert_eq!(
619            FILE_ENTRIES.len(),
620            fields.len(),
621            "FILE_ENTRIES and manifest_fields_mut must return the same length"
622        );
623    }
624
625    #[test]
626    fn test_scalar_parameters_json_and_hydro_energy_productivity_present() {
627        let dir = TempDir::new().unwrap();
628        make_case_with_required(&dir);
629        fs::create_dir_all(dir.path().join("constraints")).unwrap();
630        fs::write(
631            dir.path().join("constraints/generic_parameters.json"),
632            b"{\"scalar_parameters\":[]}",
633        )
634        .unwrap();
635        fs::write(
636            dir.path().join("system/hydro_energy_productivity.parquet"),
637            b"",
638        )
639        .unwrap();
640
641        let mut ctx = ValidationContext::new();
642        let manifest = validate_structure(dir.path(), &mut ctx);
643
644        assert!(
645            !ctx.has_errors(),
646            "no errors expected when all required files present"
647        );
648        assert!(
649            manifest.constraints_generic_parameters_json,
650            "constraints_generic_parameters_json should be true"
651        );
652        assert!(
653            manifest.system_hydro_energy_productivity_parquet,
654            "system_hydro_energy_productivity_parquet should be true"
655        );
656    }
657
658    #[test]
659    fn test_scalar_parameters_json_and_hydro_energy_productivity_absent() {
660        let dir = TempDir::new().unwrap();
661        make_case_with_required(&dir);
662        // The optional files are deliberately not created
663
664        let mut ctx = ValidationContext::new();
665        let manifest = validate_structure(dir.path(), &mut ctx);
666
667        assert!(
668            !ctx.has_errors(),
669            "absent optional files must not produce errors"
670        );
671        assert!(
672            !manifest.constraints_generic_parameters_json,
673            "constraints_generic_parameters_json should be false when file is absent"
674        );
675        assert!(
676            !manifest.system_hydro_energy_productivity_parquet,
677            "system_hydro_energy_productivity_parquet should be false when file is absent"
678        );
679    }
680
681    /// AC: a case directory containing `constraints/generic_parameters.json` must set
682    /// `manifest.constraints_generic_parameters_json == true`.
683    #[test]
684    fn manifest_detects_scalar_parameters_json_when_present() {
685        let dir = TempDir::new().unwrap();
686        make_case_with_required(&dir);
687        fs::create_dir_all(dir.path().join("constraints")).unwrap();
688        fs::write(
689            dir.path().join("constraints/generic_parameters.json"),
690            b"{\"scalar_parameters\":[]}",
691        )
692        .unwrap();
693
694        let mut ctx = ValidationContext::new();
695        let manifest = validate_structure(dir.path(), &mut ctx);
696
697        assert!(
698            !ctx.has_errors(),
699            "no errors expected when all required files are present"
700        );
701        assert!(
702            manifest.constraints_generic_parameters_json,
703            "constraints_generic_parameters_json must be true when constraints/generic_parameters.json exists"
704        );
705    }
706
707    /// AC: a case directory with no scalar parameter file must report
708    /// `manifest.constraints_generic_parameters_json == false` without producing an error.
709    #[test]
710    fn manifest_reports_absent_when_no_parameter_file() {
711        let dir = TempDir::new().unwrap();
712        make_case_with_required(&dir);
713        // constraints/generic_parameters.json is deliberately absent.
714
715        let mut ctx = ValidationContext::new();
716        let manifest = validate_structure(dir.path(), &mut ctx);
717
718        assert!(
719            !ctx.has_errors(),
720            "absent optional file must not produce errors"
721        );
722        assert!(
723            !manifest.constraints_generic_parameters_json,
724            "constraints_generic_parameters_json must be false when constraints/generic_parameters.json is absent"
725        );
726    }
727
728    #[test]
729    fn test_manifest_tailrace_curves_present() {
730        let dir = TempDir::new().unwrap();
731        make_case_with_required(&dir);
732        fs::write(dir.path().join("system/tailrace_curves.parquet"), b"").unwrap();
733
734        let mut ctx = ValidationContext::new();
735        let manifest = validate_structure(dir.path(), &mut ctx);
736
737        assert!(
738            !ctx.has_errors(),
739            "present optional file should not produce errors"
740        );
741        assert!(
742            manifest.system_tailrace_curves_parquet,
743            "system_tailrace_curves_parquet should be true when file is present"
744        );
745    }
746
747    #[test]
748    fn test_manifest_tailrace_curves_absent() {
749        let dir = TempDir::new().unwrap();
750        make_case_with_required(&dir);
751        // system/tailrace_curves.parquet deliberately absent.
752
753        let mut ctx = ValidationContext::new();
754        let manifest = validate_structure(dir.path(), &mut ctx);
755
756        assert!(
757            !ctx.has_errors(),
758            "absent optional file should not produce errors"
759        );
760        assert!(
761            !manifest.system_tailrace_curves_parquet,
762            "system_tailrace_curves_parquet should be false when file is absent"
763        );
764    }
765
766    /// AC #5: `scenarios/inflow_annual_component.parquet` present → manifest flag `true`.
767    #[test]
768    fn test_manifest_detects_inflow_annual_component_present() {
769        let dir = TempDir::new().unwrap();
770        make_case_with_required(&dir);
771        let scenarios_dir = dir.path().join("scenarios");
772        fs::create_dir_all(&scenarios_dir).unwrap();
773        fs::write(scenarios_dir.join("inflow_annual_component.parquet"), b"").unwrap();
774
775        let mut ctx = ValidationContext::new();
776        let manifest = validate_structure(dir.path(), &mut ctx);
777
778        assert!(
779            !ctx.has_errors(),
780            "present optional file should not produce errors"
781        );
782        assert!(
783            manifest.scenarios_inflow_annual_component_parquet,
784            "scenarios_inflow_annual_component_parquet should be true when file is present"
785        );
786    }
787
788    /// AC #6: `scenarios/inflow_annual_component.parquet` absent → manifest flag `false`, no error.
789    #[test]
790    fn test_manifest_inflow_annual_component_absent() {
791        let dir = TempDir::new().unwrap();
792        make_case_with_required(&dir);
793        // Do not create the optional annual component file.
794
795        let mut ctx = ValidationContext::new();
796        let manifest = validate_structure(dir.path(), &mut ctx);
797
798        assert!(
799            !ctx.has_errors(),
800            "absent optional file should not produce errors"
801        );
802        assert!(
803            !manifest.scenarios_inflow_annual_component_parquet,
804            "scenarios_inflow_annual_component_parquet should be false when file is absent"
805        );
806    }
807
808    /// AC: `constraints/hydro_unit_group_bounds.parquet` present -> manifest flag
809    /// `true`; absent (a separate case directory) -> `false`, no error either way.
810    #[test]
811    fn test_manifest_hydro_unit_group_bounds() {
812        let dir = TempDir::new().unwrap();
813        make_case_with_required(&dir);
814        let constraints_dir = dir.path().join("constraints");
815        fs::create_dir_all(&constraints_dir).unwrap();
816        fs::write(constraints_dir.join("hydro_unit_group_bounds.parquet"), b"").unwrap();
817
818        let mut ctx = ValidationContext::new();
819        let manifest = validate_structure(dir.path(), &mut ctx);
820
821        assert!(
822            !ctx.has_errors(),
823            "present optional file should not produce errors"
824        );
825        assert!(
826            manifest.constraints_hydro_unit_group_bounds_parquet,
827            "constraints_hydro_unit_group_bounds_parquet should be true when file is present"
828        );
829
830        let absent_dir = TempDir::new().unwrap();
831        make_case_with_required(&absent_dir);
832
833        let mut absent_ctx = ValidationContext::new();
834        let absent_manifest = validate_structure(absent_dir.path(), &mut absent_ctx);
835
836        assert!(
837            !absent_ctx.has_errors(),
838            "absent optional file should not produce errors"
839        );
840        assert!(
841            !absent_manifest.constraints_hydro_unit_group_bounds_parquet,
842            "constraints_hydro_unit_group_bounds_parquet should be false when file is absent"
843        );
844    }
845
846    #[test]
847    fn removed_exchange_factors_file_is_rejected() {
848        let dir = TempDir::new().unwrap();
849        make_case_with_required(&dir);
850        let constraints_dir = dir.path().join("constraints");
851        fs::create_dir_all(&constraints_dir).unwrap();
852        // An empty factor list, not garbage bytes: Layer 1 rejects on presence
853        // alone and never parses, so even a well-formed empty file is refused.
854        fs::write(
855            constraints_dir.join("exchange_factors.json"),
856            b"{\"exchange_factors\": []}",
857        )
858        .unwrap();
859
860        let mut ctx = ValidationContext::new();
861        let _manifest = validate_structure(dir.path(), &mut ctx);
862
863        assert_eq!(
864            ctx.errors().len(),
865            1,
866            "should have exactly 1 error when constraints/exchange_factors.json is present, got: {:?}",
867            ctx.errors()
868        );
869        let entry = ctx.errors()[0];
870        assert_eq!(
871            entry.kind,
872            ErrorKind::BusinessRuleViolation,
873            "removed-file rejection should carry BusinessRuleViolation"
874        );
875        assert!(
876            entry
877                .file
878                .to_string_lossy()
879                .contains("constraints/exchange_factors.json"),
880            "error file should reference constraints/exchange_factors.json, got: {}",
881            entry.file.display()
882        );
883        assert!(
884            entry.message.contains("constraints/exchange_factors.json"),
885            "message should name the removed file, got: {}",
886            entry.message
887        );
888        assert!(
889            entry.message.contains("constraints/line_bounds.parquet"),
890            "message should name the replacement file, got: {}",
891            entry.message
892        );
893    }
894
895    #[test]
896    fn removed_scalar_parameters_file_is_rejected() {
897        let dir = TempDir::new().unwrap();
898        make_case_with_required(&dir);
899        // The relocated parameters file at its old system/ path: Layer 1 rejects
900        // on presence alone and never parses, so a well-formed empty list is
901        // still refused rather than silently read.
902        fs::write(
903            dir.path().join("system/scalar_parameters.json"),
904            b"{\"scalar_parameters\": []}",
905        )
906        .unwrap();
907
908        let mut ctx = ValidationContext::new();
909        let _manifest = validate_structure(dir.path(), &mut ctx);
910
911        assert_eq!(
912            ctx.errors().len(),
913            1,
914            "should have exactly 1 error when system/scalar_parameters.json is present, got: {:?}",
915            ctx.errors()
916        );
917        let entry = ctx.errors()[0];
918        assert_eq!(
919            entry.kind,
920            ErrorKind::BusinessRuleViolation,
921            "removed-file rejection should carry BusinessRuleViolation"
922        );
923        assert!(
924            entry
925                .file
926                .to_string_lossy()
927                .contains("system/scalar_parameters.json"),
928            "error file should reference the withdrawn path, got: {}",
929            entry.file.display()
930        );
931        assert!(
932            entry
933                .message
934                .contains("constraints/generic_parameters.json"),
935            "message should name the new path so the old file is rejected loudly, got: {}",
936            entry.message
937        );
938    }
939
940    #[test]
941    fn absent_removed_file_produces_no_finding() {
942        let dir = TempDir::new().unwrap();
943        make_case_with_required(&dir);
944        // constraints/exchange_factors.json deliberately absent.
945
946        let mut ctx = ValidationContext::new();
947        let _manifest = validate_structure(dir.path(), &mut ctx);
948
949        assert!(
950            !ctx.has_errors(),
951            "absent removed file should not produce any error, got: {:?}",
952            ctx.errors()
953        );
954    }
955
956    #[test]
957    fn removed_file_check_does_not_disturb_manifest_flags() {
958        let dir = TempDir::new().unwrap();
959        make_case_with_required(&dir);
960        let constraints_dir = dir.path().join("constraints");
961        fs::create_dir_all(&constraints_dir).unwrap();
962        fs::write(constraints_dir.join("exchange_factors.json"), b"{}").unwrap();
963        fs::write(constraints_dir.join("line_bounds.parquet"), b"").unwrap();
964        fs::write(constraints_dir.join("hydro_bounds.parquet"), b"").unwrap();
965        let scenarios_dir = dir.path().join("scenarios");
966        fs::create_dir_all(&scenarios_dir).unwrap();
967        fs::write(scenarios_dir.join("load_factors.json"), b"{}").unwrap();
968
969        let mut ctx = ValidationContext::new();
970        let manifest = validate_structure(dir.path(), &mut ctx);
971
972        // The three named optional files stay tracked in the manifest; the
973        // removed file itself carries no manifest field — REMOVED_FILES is a
974        // separate rejection loop, not a FILE_ENTRIES row.
975        assert!(manifest.constraints_line_bounds_parquet);
976        assert!(manifest.constraints_hydro_bounds_parquet);
977        assert!(manifest.scenarios_load_factors_json);
978
979        // Every other optional flag stays false — pins that the removed-file
980        // loop did not shift the FILE_ENTRIES / manifest_fields_mut positional zip.
981        assert!(!manifest.system_non_controllable_sources_json);
982        assert!(!manifest.system_pumping_stations_json);
983        assert!(!manifest.system_energy_contracts_json);
984        assert!(!manifest.system_hydro_geometry_parquet);
985        assert!(!manifest.system_hydro_production_models_json);
986        assert!(!manifest.system_fpha_hyperplanes_parquet);
987        assert!(!manifest.system_hydro_energy_productivity_parquet);
988        assert!(!manifest.system_tailrace_curves_parquet);
989
990        assert!(!manifest.scenarios_inflow_history_parquet);
991        assert!(!manifest.scenarios_inflow_seasonal_stats_parquet);
992        assert!(!manifest.scenarios_inflow_ar_coefficients_parquet);
993        assert!(!manifest.scenarios_inflow_annual_component_parquet);
994        assert!(!manifest.scenarios_external_inflow_scenarios_parquet);
995        assert!(!manifest.scenarios_external_load_scenarios_parquet);
996        assert!(!manifest.scenarios_external_ncs_scenarios_parquet);
997        assert!(!manifest.scenarios_load_seasonal_stats_parquet);
998        assert!(!manifest.scenarios_correlation_json);
999        assert!(!manifest.scenarios_non_controllable_factors_json);
1000        assert!(!manifest.scenarios_non_controllable_stats_parquet);
1001
1002        assert!(!manifest.constraints_thermal_bounds_parquet);
1003        assert!(!manifest.constraints_pumping_bounds_parquet);
1004        assert!(!manifest.constraints_contract_bounds_parquet);
1005        assert!(!manifest.constraints_generic_constraints_json);
1006        assert!(!manifest.constraints_generic_constraint_bounds_parquet);
1007        assert!(!manifest.constraints_generic_parameters_json);
1008        assert!(!manifest.constraints_penalty_overrides_bus_parquet);
1009        assert!(!manifest.constraints_penalty_overrides_line_parquet);
1010        assert!(!manifest.constraints_penalty_overrides_hydro_parquet);
1011        assert!(!manifest.constraints_penalty_overrides_ncs_parquet);
1012        assert!(!manifest.constraints_ncs_bounds_parquet);
1013        assert!(!manifest.constraints_hydro_unit_group_bounds_parquet);
1014    }
1015
1016    #[test]
1017    fn similarly_named_file_alongside_removed_file_is_not_rejected() {
1018        let dir = TempDir::new().unwrap();
1019        make_case_with_required(&dir);
1020        let constraints_dir = dir.path().join("constraints");
1021        fs::create_dir_all(&constraints_dir).unwrap();
1022        // Substring "exchange", not the exact REMOVED_FILES path: pins that the
1023        // check is exact-path equality, not a substring/prefix match that would
1024        // also catch this file.
1025        fs::write(constraints_dir.join("exchange_factors_backup.json"), b"{}").unwrap();
1026
1027        let mut ctx = ValidationContext::new();
1028        let _manifest = validate_structure(dir.path(), &mut ctx);
1029
1030        assert!(
1031            !ctx.has_errors(),
1032            "a similarly-named file must not trigger the removed-file rejection, got: {:?}",
1033            ctx.errors()
1034        );
1035    }
1036}