Skip to main content

siderite_migrations/
loader.rs

1//! Load, validate and write JSON migration files.
2
3use crate::error::MigrationError;
4use crate::migration::Migration;
5use crate::state::ProjectState;
6use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
7use std::fs;
8use std::path::{Path, PathBuf};
9
10/// Load every `*.json` file in `dir`. Missing directories yield an empty list.
11/// Each file's checksum is recomputed from its content.
12///
13/// # Errors
14/// IO, JSON, or a checksum body that cannot be encoded.
15pub fn load_dir(dir: &Path) -> Result<Vec<Migration>, MigrationError> {
16    if !dir.exists() {
17        return Ok(Vec::new());
18    }
19    let mut files: Vec<PathBuf> = fs::read_dir(dir)?
20        .filter_map(|entry| {
21            let entry = entry.ok()?;
22            let path = entry.path();
23            if path.extension().is_some_and(|e| e == "json") {
24                Some(path)
25            } else {
26                None
27            }
28        })
29        .collect();
30    files.sort();
31    let mut migrations = Vec::with_capacity(files.len());
32    for path in files {
33        let text = fs::read_to_string(&path)?;
34        let mut migration: Migration = serde_json::from_str(&text)?;
35        migration.checksum = migration.compute_checksum()?;
36        migrations.push(migration);
37    }
38    Ok(migrations)
39}
40
41/// Pretty-print `migration` as `{id}.json` in `dir`.
42///
43/// # Errors
44/// IO or JSON encoding.
45pub fn write_migration(dir: &Path, migration: &Migration) -> Result<PathBuf, MigrationError> {
46    fs::create_dir_all(dir)?;
47    let path = dir.join(format!("{}.json", migration.id));
48    let mut body = serde_json::to_string_pretty(migration)?;
49    if !body.ends_with('\n') {
50        body.push('\n');
51    }
52    fs::write(&path, body)?;
53    Ok(path)
54}
55
56/// Graph of visible (non-replaced) migrations, in topological order.
57#[derive(Debug, Clone)]
58pub struct MigrationGraph {
59    /// All loaded migrations, including those replaced by a squash.
60    pub all: Vec<Migration>,
61    /// Ids in topological order (replaced ids omitted).
62    pub order: Vec<String>,
63    /// `replaced_id → squash_id`.
64    pub replaced_by: HashMap<String, String>,
65}
66
67impl MigrationGraph {
68    /// Validate dependencies, reject cycles and multiple heads, hide ids
69    /// listed in any loaded migration's `replaces`.
70    ///
71    /// # Errors
72    /// [`MigrationError::MissingDependency`], [`MigrationError::Cycle`],
73    /// [`MigrationError::MultipleHeads`].
74    pub fn build(migrations: Vec<Migration>) -> Result<Self, MigrationError> {
75        let by_id: BTreeMap<String, Migration> = {
76            let mut m = BTreeMap::new();
77            for migration in &migrations {
78                m.insert(migration.id.clone(), migration.clone());
79            }
80            m
81        };
82        let mut replaced_by: HashMap<String, String> = HashMap::new();
83        for migration in &migrations {
84            for old in &migration.replaces {
85                replaced_by.insert(old.clone(), migration.id.clone());
86            }
87        }
88        let visible: BTreeSet<String> = by_id
89            .keys()
90            .filter(|id| !replaced_by.contains_key(id.as_str()))
91            .cloned()
92            .collect();
93
94        for id in &visible {
95            let migration = &by_id[id];
96            for dep in &migration.dependencies {
97                let resolved = resolve_dep(dep, &replaced_by);
98                if !by_id.contains_key(dep) && !by_id.contains_key(&resolved) {
99                    return Err(MigrationError::MissingDependency {
100                        id: id.clone(),
101                        dependency: dep.clone(),
102                    });
103                }
104            }
105        }
106
107        let mut deps: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
108        for id in &visible {
109            let mut d = BTreeSet::new();
110            for dep in &by_id[id].dependencies {
111                let resolved = resolve_dep(dep, &replaced_by);
112                if visible.contains(&resolved) {
113                    d.insert(resolved);
114                }
115            }
116            deps.insert(id.clone(), d);
117        }
118
119        let order = topo_ids(&visible, &deps)?;
120        let heads: Vec<String> = {
121            let mut incoming: BTreeMap<String, usize> =
122                visible.iter().map(|id| (id.clone(), 0usize)).collect();
123            for id in &visible {
124                for dep in &deps[id] {
125                    if let Some(n) = incoming.get_mut(dep) {
126                        *n += 1;
127                    }
128                }
129            }
130            let mut heads: Vec<String> = incoming
131                .into_iter()
132                .filter(|(_, n)| *n == 0)
133                .map(|(id, _)| id)
134                .collect();
135            heads.sort();
136            heads
137        };
138        if heads.len() > 1 {
139            return Err(MigrationError::MultipleHeads { heads });
140        }
141
142        Ok(Self {
143            all: migrations,
144            order,
145            replaced_by,
146        })
147    }
148
149    /// Migration by id (including replaced ones).
150    pub fn get(&self, id: &str) -> Option<&Migration> {
151        self.all.iter().find(|m| m.id == id)
152    }
153
154    /// Visible migrations in topological order.
155    pub fn visible(&self) -> impl Iterator<Item = &Migration> {
156        self.order.iter().filter_map(|id| self.get(id))
157    }
158
159    /// Replay visible migrations into a [`ProjectState`].
160    ///
161    /// # Errors
162    /// [`MigrationError::State`] if an operation does not apply.
163    pub fn project_state(&self) -> Result<ProjectState, MigrationError> {
164        let mut state = ProjectState::new();
165        for migration in self.visible() {
166            for op in &migration.operations {
167                op.apply_to_state(&mut state)?;
168            }
169        }
170        Ok(state)
171    }
172}
173
174fn resolve_dep(dep: &str, replaced_by: &HashMap<String, String>) -> String {
175    replaced_by
176        .get(dep)
177        .cloned()
178        .unwrap_or_else(|| dep.to_owned())
179}
180
181fn topo_ids(
182    names: &BTreeSet<String>,
183    deps: &BTreeMap<String, BTreeSet<String>>,
184) -> Result<Vec<String>, MigrationError> {
185    let mut remaining: BTreeMap<String, usize> = names
186        .iter()
187        .map(|id| (id.clone(), deps.get(id).map(BTreeSet::len).unwrap_or(0)))
188        .collect();
189    let mut ready: BTreeSet<String> = remaining
190        .iter()
191        .filter(|(_, d)| **d == 0)
192        .map(|(id, _)| id.clone())
193        .collect();
194    let mut order = Vec::new();
195    let mut visited = HashSet::new();
196    while let Some(id) = ready.iter().next().cloned() {
197        ready.remove(&id);
198        order.push(id.clone());
199        visited.insert(id.clone());
200        remaining.remove(&id);
201        for (node, ds) in deps {
202            if ds.contains(&id)
203                && let Some(deg) = remaining.get_mut(node)
204            {
205                *deg = deg.saturating_sub(1);
206                if *deg == 0 {
207                    ready.insert(node.clone());
208                }
209            }
210        }
211    }
212    if order.len() != names.len() {
213        let leftover: Vec<String> = names
214            .difference(&visited.into_iter().collect())
215            .cloned()
216            .collect();
217        let cycle = find_cycle(deps, &leftover).unwrap_or(leftover);
218        return Err(MigrationError::Cycle { nodes: cycle });
219    }
220    Ok(order)
221}
222
223fn find_cycle(
224    deps: &BTreeMap<String, BTreeSet<String>>,
225    leftover: &[String],
226) -> Option<Vec<String>> {
227    fn visit<'a>(
228        node: &'a str,
229        deps: &'a BTreeMap<String, BTreeSet<String>>,
230        leftover: &HashSet<&str>,
231        stack: &mut Vec<&'a str>,
232        seen: &mut HashSet<&'a str>,
233    ) -> Option<Vec<String>> {
234        if let Some(i) = stack.iter().position(|n| *n == node) {
235            let mut cycle: Vec<String> = stack[i..].iter().map(|s| (*s).to_owned()).collect();
236            cycle.push(node.to_owned());
237            return Some(cycle);
238        }
239        if !seen.insert(node) {
240            return None;
241        }
242        stack.push(node);
243        if let Some(ds) = deps.get(node) {
244            for dep in ds {
245                if leftover.contains(dep.as_str())
246                    && let Some(c) = visit(dep, deps, leftover, stack, seen)
247                {
248                    return Some(c);
249                }
250            }
251        }
252        stack.pop();
253        None
254    }
255    let set: HashSet<&str> = leftover.iter().map(String::as_str).collect();
256    let mut seen = HashSet::new();
257    for id in leftover {
258        if let Some(c) = visit(id, deps, &set, &mut Vec::new(), &mut seen) {
259            return Some(c);
260        }
261    }
262    None
263}
264
265/// Whether `migration` should be treated as already applied given `history`.
266pub fn is_applied(migration: &Migration, history: &HashSet<String>) -> bool {
267    if history.contains(&migration.id) {
268        return true;
269    }
270    !migration.replaces.is_empty() && migration.replaces.iter().all(|id| history.contains(id))
271}