ergol_cli/
lib.rs

1pub mod db;
2pub mod diff;
3
4use std::env::current_dir;
5use std::error::Error;
6use std::fs::{copy, create_dir, read_dir, read_to_string, File};
7use std::io::Write;
8use std::path::{Path, PathBuf};
9
10use toml::Value;
11
12use ergol_core::{Element, Table};
13
14use crate::diff::{diff, Diff, State};
15
16/// Tries to sort the tables in order to avoid problems with dependencies.
17pub fn order(tables: Vec<Table>) -> Vec<Table> {
18    let mut current: Vec<String> = vec![];
19    let mut output_tables = vec![];
20    let len = tables.len();
21
22    for _ in 0..len {
23        for table in &tables {
24            // Check dependencies
25            if !current.contains(&table.name)
26                && table.dependencies().iter().all(|x| current.contains(x))
27            {
28                current.push(table.name.clone());
29                output_tables.push(table.clone());
30            }
31        }
32    }
33
34    if output_tables.len() != len {
35        tables
36    } else {
37        output_tables
38    }
39}
40
41/// Find cargo toml.
42pub fn find_cargo_toml() -> Option<PathBuf> {
43    let mut current = current_dir().ok()?;
44
45    loop {
46        if current.join("Cargo.toml").is_file() {
47            return Some(current);
48        }
49
50        if !current.pop() {
51            return None;
52        }
53    }
54}
55
56/// Finds the last saved db state.
57pub fn last_saved_state<P: AsRef<Path>>(p: P) -> Result<(Option<u32>, State), Box<dyn Error>> {
58    let p = p.as_ref();
59    let mut current = 0;
60
61    loop {
62        if !p.join(format!("{}", current)).is_dir() {
63            if current == 0 {
64                // Last state is empty.
65                return Ok((None, (vec![], vec![])));
66            } else {
67                return state_from_dir(p.join(format!("{}", current - 1)))
68                    .map(|x| (Some(current - 1), x));
69            }
70        }
71
72        current += 1;
73    }
74}
75
76/// Returns the db state from a directory.
77pub fn state_from_dir<P: AsRef<Path>>(path: P) -> Result<State, Box<dyn Error>> {
78    let mut tables = vec![];
79    let mut enums = vec![];
80
81    for file in read_dir(path.as_ref())? {
82        let path = file?.path();
83        if path.extension().and_then(|x| x.to_str()) == Some("json") {
84            let content = read_to_string(path)?;
85            let elements: Vec<Element> = serde_json::from_str(&content)?;
86            for element in elements {
87                match element {
88                    Element::Enum(e) => enums.push(e),
89                    Element::Table(t) => tables.push(t),
90                }
91            }
92        }
93    }
94    Ok((enums, order(tables)))
95}
96
97/// Tries to find the database URL in Rocket.toml or Ergol.toml.
98pub fn find_db_url<P: AsRef<Path>>(path: P) -> Option<String> {
99    let path = path.as_ref();
100
101    let path = if path.join("Ergol.toml").is_file() {
102        path.join("Ergol.toml")
103    } else if path.join("Rocket.toml").is_file() {
104        path.join("Rocket.toml")
105    } else {
106        return None;
107    };
108
109    let content = read_to_string(path).ok()?;
110    let value = content.parse::<Value>().ok()?;
111
112    let url = value
113        .as_table()?
114        .get("default")?
115        .as_table()?
116        .get("databases")?
117        .as_table()?
118        .get("database")?
119        .as_table()?
120        .get("url")?
121        .as_str()?;
122
123    Some(url.into())
124}
125
126/// Runs the ergol migrations.
127pub async fn migrate<P: AsRef<Path>>(path: P) -> Result<(), Box<dyn Error>> {
128    let path = path.as_ref();
129    let db_url = find_db_url(&path).unwrap();
130
131    let (db, connection) = tokio_postgres::connect(&db_url, tokio_postgres::NoTls).await?;
132
133    tokio::spawn(async move {
134        if let Err(e) = connection.await {
135            eprintln!("connection error: {}", e);
136        }
137    });
138
139    let current = db::current_migration(&db).await?;
140
141    let mut current = match current {
142        Some(i) => i + 1,
143        None => {
144            db::create_current_migration(&db).await?;
145            0
146        }
147    };
148
149    // We need to run migrations starting with current.
150    loop {
151        let path = path.join(format!("migrations/{}/up.sql", current));
152
153        if !path.is_file() {
154            break;
155        }
156
157        let up = read_to_string(path)?;
158        println!("{}", up);
159
160        db.simple_query(&up as &str).await?;
161        db::set_migration(current, &db).await?;
162
163        current += 1;
164    }
165
166    Ok(())
167}
168
169/// Returns the migration diff between last save state and current state.
170pub fn current_diff<P: AsRef<Path>>(path: P) -> Result<Diff, Box<dyn Error>> {
171    let path = path.as_ref();
172
173    let last = last_saved_state(path.join("migrations"))?;
174    let current = state_from_dir(path.join("migrations/current"))?;
175
176    Ok(diff(last.1, current))
177}
178
179/// Delete the whole database.
180pub async fn delete<P: AsRef<Path>>(path: P) -> Result<(), Box<dyn Error>> {
181    let path = path.as_ref();
182    let db_url = find_db_url(&path).unwrap();
183
184    let (db, connection) = tokio_postgres::connect(&db_url, tokio_postgres::NoTls).await?;
185
186    tokio::spawn(async move {
187        if let Err(e) = connection.await {
188            eprintln!("connection error: {}", e);
189        }
190    });
191
192    db::clear(&db).await?;
193
194    Ok(())
195}
196
197/// Saves the current state in a new migration.
198pub fn save<P: AsRef<Path>>(p: P) -> Result<(), Box<dyn Error>> {
199    let p = p.as_ref();
200    let (last_index, last_state) = last_saved_state(p)?;
201    let current_state = state_from_dir(p.join("current"))?;
202    let current_index = match last_index {
203        None => 0,
204        Some(i) => i + 1,
205    };
206
207    let save_dir = p.join(format!("{}", current_index));
208    create_dir(&save_dir)?;
209    for f in read_dir(p.join("current"))? {
210        let path = f?.path();
211        copy(&path, &save_dir.join(path.file_name().unwrap()))?;
212    }
213
214    let diff = diff(last_state, current_state);
215    let mut file = File::create(save_dir.join("up.sql"))?;
216    file.write_all(diff.hint().as_bytes())?;
217
218    let mut file = File::create(save_dir.join("down.sql"))?;
219    file.write_all(diff.hint_revert().as_bytes())?;
220
221    Ok(())
222}
223
224/// Resets the database to the current state.
225pub async fn reset<P: AsRef<Path>>(p: P) -> Result<(), Box<dyn Error>> {
226    let p = p.as_ref();
227    delete(p).await?;
228    let (enums, tables) = state_from_dir(p.join("migrations/current"))?;
229
230    let db_url = find_db_url(p).unwrap();
231    let (db, connection) = tokio_postgres::connect(&db_url, tokio_postgres::NoTls).await?;
232
233    tokio::spawn(async move {
234        if let Err(e) = connection.await {
235            eprintln!("connection error: {}", e);
236        }
237    });
238
239    for e in enums {
240        db.query(&Element::Enum(e).create() as &str, &[]).await?;
241    }
242
243    for t in tables {
244        db.query(&Element::Table(t).create() as &str, &[]).await?;
245    }
246
247    Ok(())
248}