use crate::migration::section_parser::MigrationSection;
use crate::migration_tracking::section_tracking::SectionStatus;
use crate::modules::{ModuleSelection, remap_source_held};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkipNotice {
Drift,
NotEstablished,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkippedModule {
pub module: String,
pub notice: SkipNotice,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CouplingViolation {
pub module: String,
pub earlier_section: String,
pub section: String,
}
#[derive(Debug, Clone, Default)]
pub struct SectionClassification {
pub to_run: Vec<(i32, MigrationSection)>,
pub to_satisfy: Vec<(i32, MigrationSection)>,
pub selected: Vec<(i32, MigrationSection)>,
pub skipped: Vec<SkippedModule>,
pub coupling_violation: Option<CouplingViolation>,
}
pub(crate) fn section_selected(
section: &MigrationSection,
vocabulary: &BTreeSet<String>,
selection: &ModuleSelection,
) -> bool {
if section.remaps.is_none() {
selection.selects(section.module.as_deref())
} else {
match section.module.as_deref() {
None => true,
Some(m) => vocabulary.contains(m) || selection.selects(Some(m)),
}
}
}
fn is_covered(statuses: &BTreeMap<String, SectionStatus>, name: &str) -> bool {
statuses.get(name).is_some_and(|st| st.is_covered())
}
pub fn classify_sections(
sections: &[MigrationSection],
statuses: &BTreeMap<String, SectionStatus>,
vocabulary: &BTreeSet<String>,
selection: &ModuleSelection,
established: &BTreeSet<String>,
) -> SectionClassification {
let selected: Vec<(i32, MigrationSection)> = sections
.iter()
.enumerate()
.filter(|(_, s)| section_selected(s, vocabulary, selection))
.map(|(i, s)| (i as i32, s.clone()))
.collect();
let to_run: Vec<(i32, MigrationSection)> = selected
.iter()
.filter(|(_, s)| !remap_source_held(s, established))
.filter(|(_, s)| !is_covered(statuses, &s.name))
.cloned()
.collect();
let to_satisfy: Vec<(i32, MigrationSection)> = selected
.iter()
.filter(|(_, s)| remap_source_held(s, established))
.filter(|(_, s)| !is_covered(statuses, &s.name))
.cloned()
.collect();
let mut skipped_modules: BTreeSet<&str> = BTreeSet::new();
for section in sections {
if let Some(module) = section.module.as_deref()
&& !section_selected(section, vocabulary, selection)
&& !is_covered(statuses, §ion.name)
{
skipped_modules.insert(module);
}
}
let skipped = skipped_modules
.into_iter()
.map(|module| SkippedModule {
module: module.to_string(),
notice: if vocabulary.contains(module) {
SkipNotice::Drift
} else {
SkipNotice::NotEstablished
},
})
.collect();
let mut coupling_violation = None;
'outer: for (idx, section) in sections.iter().enumerate() {
if !section_selected(section, vocabulary, selection) {
continue;
}
for earlier in §ions[..idx] {
if let Some(module) = earlier.module.as_deref()
&& !section_selected(earlier, vocabulary, selection)
&& vocabulary.contains(module)
&& !is_covered(statuses, &earlier.name)
{
coupling_violation = Some(CouplingViolation {
module: module.to_string(),
earlier_section: earlier.name.clone(),
section: section.name.clone(),
});
break 'outer;
}
}
}
SectionClassification {
to_run,
to_satisfy,
selected,
skipped,
coupling_violation,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::migration::section_parser::TransactionMode;
use crate::modules::run_eligible;
use std::time::Duration;
fn section(name: &str, module: Option<&str>, remaps: Option<&str>) -> MigrationSection {
MigrationSection {
name: name.to_string(),
description: None,
mode: TransactionMode::Transactional,
timeout: Duration::from_secs(30),
lock_timeout: None,
retry_config: None,
sql: format!("-- {name}"),
raw_header: format!("-- pgmt:section name={name}"),
module: module.map(str::to_string),
remaps: remaps.map(str::to_string),
start_line: 1,
}
}
fn set(names: &[&str]) -> BTreeSet<String> {
names.iter().map(|s| s.to_string()).collect()
}
fn named(names: &[&str]) -> ModuleSelection {
ModuleSelection::Named(set(names))
}
fn statuses(pairs: &[(&str, SectionStatus)]) -> BTreeMap<String, SectionStatus> {
pairs
.iter()
.map(|(n, s)| (n.to_string(), s.clone()))
.collect()
}
fn names(v: &[(i32, MigrationSection)]) -> Vec<String> {
v.iter().map(|(_, s)| s.name.clone()).collect()
}
#[test]
fn plain_section_selected_only_when_requested() {
let secs = vec![
section("base", None, None),
section("app", Some("app"), None),
];
let c = classify_sections(
&secs,
&BTreeMap::new(),
&BTreeSet::new(),
&named(&[]),
&BTreeSet::new(),
);
assert_eq!(names(&c.to_run), vec!["base"]);
assert!(c.to_satisfy.is_empty());
assert_eq!(c.skipped.len(), 1);
assert_eq!(c.skipped[0].module, "app");
assert_eq!(c.skipped[0].notice, SkipNotice::NotEstablished);
let c = classify_sections(
&secs,
&BTreeMap::new(),
&BTreeSet::new(),
&named(&["app"]),
&BTreeSet::new(),
);
assert_eq!(names(&c.to_run), vec!["base", "app"]);
assert!(c.skipped.is_empty());
}
#[test]
fn established_but_unselected_module_is_drift() {
let secs = vec![section("app", Some("app"), None)];
let c = classify_sections(
&secs,
&BTreeMap::new(),
&set(&["app"]),
&named(&[]),
&set(&["app"]),
);
assert!(c.to_run.is_empty());
assert_eq!(c.skipped[0].notice, SkipNotice::Drift);
}
#[test]
fn remap_source_held_records_satisfied_absent_runs() {
let secs = vec![section("analytics", Some("analytics"), Some("app"))];
let held = classify_sections(
&secs,
&BTreeMap::new(),
&set(&["analytics"]),
&named(&[]),
&set(&["app"]),
);
assert!(held.to_run.is_empty());
assert_eq!(names(&held.to_satisfy), vec!["analytics"]);
let absent = classify_sections(
&secs,
&BTreeMap::new(),
&set(&["analytics"]),
&named(&[]),
&BTreeSet::new(),
);
assert_eq!(names(&absent.to_run), vec!["analytics"]);
assert!(absent.to_satisfy.is_empty());
}
#[test]
fn covered_sections_excluded_from_to_run_and_to_satisfy() {
let secs = vec![
section("base", None, None),
section("analytics", Some("analytics"), Some("app")),
];
let c = classify_sections(
&secs,
&statuses(&[
("base", SectionStatus::Completed),
("analytics", SectionStatus::Satisfied),
]),
&set(&["analytics"]),
&named(&[]),
&set(&["app"]),
);
assert!(c.to_run.is_empty());
assert!(c.to_satisfy.is_empty());
assert_eq!(c.selected.len(), 2);
}
#[test]
fn pending_but_uncovered_section_still_runs() {
let secs = vec![section("base", None, None)];
let c = classify_sections(
&secs,
&statuses(&[("base", SectionStatus::Failed)]),
&BTreeSet::new(),
&named(&[]),
&BTreeSet::new(),
);
assert_eq!(names(&c.to_run), vec!["base"]);
}
#[test]
fn coupling_violation_detected() {
let secs = vec![
section("app_first", Some("app"), None),
section("base_after", None, None),
];
let c = classify_sections(
&secs,
&BTreeMap::new(),
&set(&["app"]),
&named(&[]),
&set(&["app"]),
);
let v = c.coupling_violation.expect("coupling violation expected");
assert_eq!(v.module, "app");
assert_eq!(v.earlier_section, "app_first");
assert_eq!(v.section, "base_after");
}
#[test]
fn no_coupling_when_earlier_section_covered() {
let secs = vec![
section("app_first", Some("app"), None),
section("base_after", None, None),
];
let c = classify_sections(
&secs,
&statuses(&[("app_first", SectionStatus::Completed)]),
&set(&["app"]),
&named(&[]),
&set(&["app"]),
);
assert!(c.coupling_violation.is_none());
}
#[test]
fn gate_run_eligibility_matches_classifier_to_run() {
let secs = vec![
section("c_from_a", Some("c"), Some("a")),
section("c_from_b", Some("c"), Some("b")),
];
let vocabulary = set(&["c"]); let established = set(&["a"]);
let plain_modules = BTreeSet::new();
let c = classify_sections(
&secs,
&BTreeMap::new(),
&vocabulary,
&named(&[]),
&established,
);
let module_c = Some("c".to_string());
assert!(run_eligible(&module_c, &established, &plain_modules));
assert_eq!(names(&c.to_satisfy), vec!["c_from_a"]);
assert_eq!(names(&c.to_run), vec!["c_from_b"]);
}
}