persistant_migrate/
lib.rs1use persistant_relational::Migration;
11use sha2::{Digest, Sha384};
12use std::path::Path;
13
14#[derive(Debug)]
16pub enum LoadError {
17 Read(String),
19 UnpairedDown {
21 version: i64,
23 },
24 Unsupported(persistant_sqlx::FromSqlxError),
26}
27
28impl std::fmt::Display for LoadError {
29 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30 match self {
31 LoadError::Read(m) => write!(f, "reading the migrations: {m}"),
32 LoadError::UnpairedDown { version } => {
33 write!(f, "migration {version} has a .down.sql but no .up.sql")
34 }
35 LoadError::Unsupported(e) => write!(f, "{e}"),
36 }
37 }
38}
39
40impl std::error::Error for LoadError {}
41
42pub async fn load_dir(dir: &Path) -> Result<Vec<Migration>, LoadError> {
44 let entries = std::fs::read_dir(dir).map_err(|e| LoadError::Read(e.to_string()))?;
45 let mut ups = Vec::new();
46 let mut downs = Vec::new();
47 for entry in entries {
48 let name = entry
49 .map_err(|e| LoadError::Read(e.to_string()))?
50 .file_name()
51 .to_string_lossy()
52 .into_owned();
53 let version = name.split('_').next().and_then(|v| v.parse::<i64>().ok());
54 if let Some(v) = version {
55 if name.ends_with(".down.sql") {
56 downs.push(v);
57 } else if name.ends_with(".sql") {
58 ups.push(v);
59 }
60 }
61 }
62 if let Some(&version) = downs.iter().find(|d| !ups.contains(d)) {
63 return Err(LoadError::UnpairedDown { version });
64 }
65 let migrator = sqlx::migrate::Migrator::new(dir)
66 .await
67 .map_err(|e| LoadError::Read(e.to_string()))?;
68 persistant_sqlx::migrations_from_sqlx(&migrator).map_err(LoadError::Unsupported)
69}
70
71pub fn checksum(m: &Migration) -> Vec<u8> {
73 Sha384::digest(m.sql.as_bytes()).to_vec()
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum State {
79 Applied,
81 Pending,
83 Changed,
85 Unknown,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct Line {
92 pub version: i64,
94 pub description: String,
96 pub state: State,
98}
99
100pub fn status(migrations: &[Migration], ledger: &[(i64, Vec<u8>)]) -> Vec<Line> {
102 let mut out: Vec<Line> = migrations
103 .iter()
104 .map(|m| {
105 let state = match ledger.iter().find(|(v, _)| *v == m.version) {
106 None => State::Pending,
107 Some((_, c)) if *c == checksum(m) => State::Applied,
108 Some(_) => State::Changed,
109 };
110 Line {
111 version: m.version,
112 description: m.description.clone(),
113 state,
114 }
115 })
116 .collect();
117 out.extend(
118 ledger
119 .iter()
120 .filter(|(v, _)| !migrations.iter().any(|m| m.version == *v))
121 .map(|(v, _)| Line {
122 version: *v,
123 description: String::new(),
124 state: State::Unknown,
125 }),
126 );
127 out.sort_by_key(|l| l.version);
128 out
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct Target {
134 pub display: String,
136 pub local: bool,
138}
139
140pub fn describe_target(url: &str) -> Target {
142 if url.starts_with("sqlite:") {
143 let path = url.trim_start_matches("sqlite:").trim_start_matches("//");
144 return Target {
145 display: format!("sqlite:{path}"),
146 local: true,
147 };
148 }
149 let Some((scheme, rest)) = url.split_once("://") else {
150 return Target {
151 display: "(unreadable URL)".to_string(),
152 local: false,
153 };
154 };
155 let (rest, query) = rest.split_once('?').unwrap_or((rest, ""));
156 let (authority, db) = rest.split_once('/').unwrap_or((rest, ""));
157 let (user, hostport) = match authority.rsplit_once('@') {
158 Some((cred, hp)) => (cred.split(':').next().unwrap_or(""), hp),
159 None => ("", authority),
160 };
161 let host_from_authority = if let Some(v6) = hostport.strip_prefix('[') {
162 v6.split(']').next().unwrap_or("")
163 } else {
164 hostport.split(':').next().unwrap_or("")
165 };
166 let host = query
168 .split('&')
169 .find_map(|kv| kv.strip_prefix("host="))
170 .unwrap_or(host_from_authority);
171 let local = host.is_empty()
172 || host.starts_with('/')
173 || host == "localhost"
174 || host == "::1"
175 || host.starts_with("127.");
176 let at = if user.is_empty() {
177 String::new()
178 } else {
179 format!("{user}@")
180 };
181 let shown_host = if host == host_from_authority {
182 hostport.to_string()
183 } else {
184 format!("{hostport} (host={host})")
185 };
186 Target {
187 display: format!("{scheme}://{at}{shown_host}/{db}"),
188 local,
189 }
190}