use crate::budget;
use crate::client;
use crate::config::{Config, ConfigEntry};
use crate::daemon;
use crate::doctor;
use crate::envfile;
use crate::error::{ErrorCode, MitosisError, Result};
use crate::mcp;
use crate::names::{format_duration, parse_duration, validate_name};
use crate::paths::Home;
use crate::protocol::{
CloneCreateArgs, CloneInfo, MigrateCmd, Request, Response, TemplateInfo, TemplateInitArgs,
TemplateSetArgs, TemplateSettings, TemplateSource,
};
use crate::service;
use crate::templates::now;
use clap::{Parser, Subcommand};
use std::process::ExitCode;
use std::time::{Duration, Instant};
#[derive(Parser)]
#[command(
name = "mitosis",
version,
about = "Copy-on-write Postgres clones for AI agent tasks"
)]
struct Cli {
#[command(subcommand)]
command: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
Template {
#[command(subcommand)]
command: TemplateCmd,
},
Clone {
#[arg(long, default_value = "main")]
template: String,
#[arg(long)]
name: Option<String>,
#[arg(long, default_value = "2h")]
ttl: String,
#[arg(long)]
durable: bool,
#[arg(long)]
fresh: bool,
#[arg(long)]
protect: bool,
#[arg(long)]
env_file: Option<std::path::PathBuf>,
#[arg(long, requires = "env_file")]
env_var: Option<String>,
#[arg(long)]
wait: Option<String>,
#[arg(long)]
json: bool,
},
Env {
id_or_name: String,
#[arg(long, default_value = "DATABASE_URL")]
var: String,
},
List {
#[arg(long)]
json: bool,
},
Top,
Destroy {
id_or_name: String,
#[arg(long)]
force: bool,
#[arg(long)]
forget: bool,
},
Promote {
clone: String,
#[arg(long)]
template: Option<String>,
},
Gc,
Doctor {
#[arg(long)]
fix: bool,
#[arg(long)]
json: bool,
},
Daemon {
#[command(subcommand)]
command: Option<DaemonCmd>,
},
Mcp,
Service {
#[command(subcommand)]
command: ServiceCmd,
},
Config {
#[command(subcommand)]
command: Option<ConfigCmd>,
#[arg(long)]
json: bool,
},
}
#[derive(Subcommand)]
enum ConfigCmd {
Set {
key: String,
value: String,
},
}
#[derive(Subcommand)]
enum TemplateCmd {
Init {
#[arg(default_value = "main")]
name: String,
#[arg(long, conflicts_with = "seed", required_unless_present = "seed")]
from_url: Option<String>,
#[arg(long)]
seed: Option<String>,
#[arg(long)]
database: Option<String>,
#[arg(long)]
migrations: Option<std::path::PathBuf>,
#[arg(long)]
pg_version: Option<u32>,
#[command(flatten)]
settings: SettingsFlags,
},
Refresh {
#[arg(default_value = "main")]
name: String,
#[arg(long)]
layered: bool,
},
Set {
#[arg(default_value = "main")]
name: String,
#[command(flatten)]
settings: SettingsFlags,
#[arg(long)]
resume: bool,
},
List {
#[arg(long)]
json: bool,
},
History {
#[arg(default_value = "main")]
name: String,
#[arg(long)]
json: bool,
},
Rollback {
#[arg(default_value = "main")]
name: String,
#[arg(long)]
to: Option<u32>,
},
Upgrade {
name: String,
#[arg(long)]
to: u32,
#[arg(long = "as")]
as_name: Option<String>,
},
}
#[derive(clap::Args)]
struct SettingsFlags {
#[arg(long)]
max_age: Option<String>,
#[arg(long)]
keep: Option<u32>,
#[arg(long)]
every: Option<String>,
#[arg(long, value_parser = ["full", "layered"])]
mode: Option<String>,
#[arg(long)]
migrate: Option<String>,
#[arg(long, value_parser = ["on", "off"])]
auto_migrate: Option<String>,
#[arg(long, value_parser = ["warn", "refresh"])]
on_stale: Option<String>,
}
fn enum_arg<T: serde::de::DeserializeOwned>(s: Option<String>) -> Result<Option<T>> {
Ok(
s.map(|s| serde_json::from_value(serde_json::Value::String(s)))
.transpose()?,
)
}
impl SettingsFlags {
fn parse(self) -> Result<(Option<u64>, TemplateSettings)> {
let max_age_secs = self
.max_age
.as_deref()
.map(parse_duration)
.transpose()?
.map(|d| d.as_secs());
let (every_secs, every_off) = match self.every.as_deref() {
Some("off") => (None, true),
Some(d) => (Some(parse_duration(d)?.as_secs()), false),
None => (None, false),
};
let (migrate, migrate_off) = match self.migrate {
Some(c) if c == "off" => (None, true),
Some(command) => (
Some(MigrateCmd {
command,
cwd: std::env::current_dir()?,
path: std::env::var("PATH").ok(),
}),
false,
),
None => (None, false),
};
let settings = TemplateSettings {
keep: self.keep,
every_secs,
every_off,
every_mode: enum_arg(self.mode)?,
migrate,
migrate_off,
auto_migrate: self.auto_migrate.map(|a| a == "on"),
on_stale: enum_arg(self.on_stale)?,
};
Ok((max_age_secs, settings))
}
}
#[derive(Subcommand)]
enum ServiceCmd {
Install,
Uninstall,
Status {
#[arg(long)]
json: bool,
},
}
#[derive(Subcommand)]
enum DaemonCmd {
Status,
Stop,
}
pub fn main() -> ExitCode {
let cli = Cli::parse();
let rt = match tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => return fail(&MitosisError::from(e)),
};
match rt.block_on(run(cli)) {
Ok(code) => code,
Err(e) => fail(&e),
}
}
fn fail(e: &MitosisError) -> ExitCode {
eprintln!("error: {}", e.message);
if let Some(hint) = &e.hint {
eprintln!("hint: {hint}");
}
if e.code == ErrorCode::AlreadyRunning {
if std::env::var_os("MITOSIS_SERVICE").is_some_and(|v| v == "1") {
return ExitCode::SUCCESS;
}
return ExitCode::from(client::EXIT_ALREADY_RUNNING);
}
ExitCode::FAILURE
}
fn unexpected(_: Response) -> MitosisError {
MitosisError::new(ErrorCode::ProtocolMismatch, "unexpected daemon reply")
.with_hint("restart the daemon: mitosis daemon stop")
}
fn print_json<T: serde::Serialize>(v: &T) -> Result<()> {
println!("{}", serde_json::to_string_pretty(v)?);
Ok(())
}
async fn run(cli: Cli) -> Result<ExitCode> {
let home = Home::from_env()?;
match cli.command {
Cmd::Daemon { command: None } => {
let config = Config::load(&home)?;
daemon::run(home, config).await?
}
Cmd::Mcp => mcp::serve_stdio(home).await?,
Cmd::Service { command } => match command {
ServiceCmd::Install => {
service::install()?;
let exe = std::env::current_exe()?;
println!("service installed: the daemon starts at login");
println!("binary {}", exe.display());
if exe.components().any(|c| c.as_os_str() == "target") {
eprintln!(
"warning: the service points at a build folder; it breaks if you run cargo clean (use cargo install for a stable path)"
);
}
}
ServiceCmd::Uninstall => {
service::uninstall()?;
println!("service removed");
}
ServiceCmd::Status { json } => {
let s = service::status()?;
if json {
print_json(&s)?;
} else {
println!("installed {}", s.installed);
println!("loaded {}", s.loaded);
println!("running {}", s.running);
if let Some(b) = &s.binary {
let gone = if s.binary_exists { "" } else { " (missing)" };
println!("binary {}{gone}", b.display());
}
}
}
},
Cmd::Config {
command: None,
json,
} => {
let entries = Config::describe(&home)?;
if json {
print_json(&entries)?;
} else {
print_config(&entries);
}
}
Cmd::Config {
command: Some(ConfigCmd::Set { key, value }),
..
} => {
Config::set_in_file(&home, &key, &value)?;
client::request_if_running(&home, Request::ConfigReload).await?;
println!("{key} = {}", value.trim());
}
Cmd::Template { command } => template(&home, command).await?,
Cmd::Clone {
template,
name,
ttl,
durable,
fresh,
protect,
env_file,
env_var,
wait,
json,
} => {
validate_name("template", &template)?;
if let Some(n) = &name {
validate_name("clone", n)?;
}
let ttl_secs = parse_duration(&ttl)?.as_secs();
let wait_secs = match wait.as_deref() {
None => None,
Some("0" | "0s") => Some(0),
Some(w) => Some(parse_duration(w)?.as_secs()),
};
let args = CloneCreateArgs {
template: template.clone(),
name,
ttl_secs,
durable,
fresh,
protect,
parent_pid: None,
env: None,
wait_secs,
};
let info = envfile::create_clone(&home, args, env_file, env_var).await?;
for w in &info.warnings {
eprintln!("warning: {w}");
}
if info.existing {
eprintln!("note: clone {} was already running", info.id);
}
if let Ok(Response::Templates { templates }) =
client::request(&home, Request::TemplateList).await
{
for w in templates
.iter()
.filter(|t| t.name == template)
.flat_map(|t| &t.warnings)
{
eprintln!("warning: {w}");
}
}
if json {
print_json(&info)?;
} else {
println!("{}", info.url);
}
}
Cmd::Env { id_or_name, var } => {
let clones = match client::request(&home, Request::CloneList).await? {
Response::Clones { clones } => clones,
other => return Err(unexpected(other)),
};
let c = clones
.iter()
.find(|c| c.id == id_or_name || c.name.as_deref() == Some(id_or_name.as_str()))
.ok_or_else(|| {
MitosisError::new(
ErrorCode::CloneNotFound,
format!("clone {id_or_name} not found"),
)
.with_hint("see running clones with `mitosis list`")
})?;
println!("{var}={}", c.url);
}
Cmd::List { json } => {
let clones = match client::request(&home, Request::CloneList).await? {
Response::Clones { clones } => clones,
other => return Err(unexpected(other)),
};
for w in envfile::finish_pending(&home).await {
eprintln!("warning: {w}");
}
if json {
print_json(&clones)?;
} else {
print_clones(&clones);
}
}
Cmd::Top => top(&home).await?,
Cmd::Destroy {
id_or_name,
force,
forget,
} => {
let req = Request::CloneDestroy {
id_or_name,
force,
forget,
};
match client::request(&home, req).await? {
Response::Destroyed { id } => println!("{id}"),
other => return Err(unexpected(other)),
}
for w in envfile::finish_pending(&home).await {
eprintln!("warning: {w}");
}
}
Cmd::Promote { clone, template } => {
let req = Request::ClonePromote {
id_or_name: clone.clone(),
template,
};
match client::request(&home, req).await? {
Response::TemplateBuilt(t) => println!(
"promoted {clone} to template {} generation {} (Postgres {}, {})",
t.name,
t.current_gen,
t.pg_major,
human_bytes(t.size_bytes)
),
other => return Err(unexpected(other)),
}
}
Cmd::Doctor { fix, json } => {
let checks = doctor::run(&home, fix).await;
if json {
print_json(&checks)?;
} else {
print!("{}", doctor::render(&checks));
}
return Ok(ExitCode::from(doctor::exit_code(&checks)));
}
Cmd::Gc => match client::request(&home, Request::Gc).await? {
Response::Gc(r) => {
println!(
"adopted {}, destroyed {}, orphan folders removed {}, records dropped {}, failed {}",
r.adopted.len(),
r.destroyed.len(),
r.orphan_folders_removed.len(),
r.records_dropped.len(),
r.failed.len()
);
for f in &r.failed {
println!("failed: {}: {}", f.item, f.error);
}
}
other => return Err(unexpected(other)),
},
Cmd::Daemon {
command: Some(DaemonCmd::Status),
} => match client::request_if_running(&home, Request::Status).await? {
None => {
println!("daemon is not running");
return Ok(ExitCode::FAILURE);
}
Some(Response::Status(s)) => {
println!("daemon is running");
println!("pid {}", s.pid);
println!("version {}", s.version);
println!("service {}", s.service);
println!("home {}", s.home.display());
println!("clones {}", s.clones);
println!("templates {}", s.templates);
println!(
"uptime {}",
format_duration(Duration::from_secs(s.uptime_secs))
);
}
Some(other) => return Err(unexpected(other)),
},
Cmd::Daemon {
command: Some(DaemonCmd::Stop),
} => {
match client::request_if_running(&home, Request::Shutdown).await? {
None => println!("daemon is not running"),
Some(Response::Ok) => {
let deadline = Instant::now() + Duration::from_secs(5);
while home.daemon_sock().exists() && Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(50)).await;
}
println!("daemon stopped");
}
Some(other) => return Err(unexpected(other)),
}
}
}
Ok(ExitCode::SUCCESS)
}
fn print_config(entries: &[ConfigEntry]) {
let width = entries.iter().map(|e| e.key.len()).max().unwrap_or(0);
let vwidth = entries.iter().map(|e| e.value.len()).max().unwrap_or(0);
for e in entries {
println!(
"{:<width$} = {:<vwidth$} ({})",
e.key,
e.value,
e.source.label()
);
}
}
async fn template(home: &Home, cmd: TemplateCmd) -> Result<()> {
match cmd {
TemplateCmd::Init {
name,
from_url,
seed,
database,
migrations,
pg_version,
settings,
} => {
validate_name("template", &name)?;
let (max_age_secs, settings) = settings.parse()?;
let migrations = migrations
.map(|dir| {
std::fs::canonicalize(&dir).map_err(|e| {
MitosisError::new(
ErrorCode::InvalidRequest,
format!("migrations folder {}: {e}", dir.display()),
)
})
})
.transpose()?;
let source = match (from_url, seed) {
(Some(url), _) => TemplateSource::Url { url },
(None, Some(command)) => TemplateSource::Seed {
command,
cwd: Some(std::env::current_dir()?),
path: std::env::var("PATH").ok(),
},
(None, None) => unreachable!("clap requires --from-url or --seed"),
};
let args = TemplateInitArgs {
name,
source,
database,
migrations,
max_age_secs,
pg_major: pg_version,
settings,
};
match client::request(home, Request::TemplateInit(args)).await? {
Response::TemplateBuilt(t) => print_built(&t),
other => return Err(unexpected(other)),
}
}
TemplateCmd::Refresh { name, layered } => {
validate_name("template", &name)?;
let path = std::env::var("PATH").ok();
let req = Request::TemplateRefresh {
name,
path,
layered,
};
match client::request(home, req).await? {
Response::TemplateBuilt(t) => print_built(&t),
other => return Err(unexpected(other)),
}
}
TemplateCmd::Set {
name,
settings,
resume,
} => {
validate_name("template", &name)?;
let (max_age_secs, settings) = settings.parse()?;
let args = TemplateSetArgs {
name,
max_age_secs,
settings,
resume,
};
match client::request(home, Request::TemplateSet(args)).await? {
Response::TemplateBuilt(t) => {
println!("template {} settings saved", t.name);
if t.paused {
println!("{}", paused_line(&t.name));
}
}
other => return Err(unexpected(other)),
}
}
TemplateCmd::List { json } => {
let templates = match client::request(home, Request::TemplateList).await? {
Response::Templates { templates } => templates,
other => return Err(unexpected(other)),
};
for t in &templates {
for w in &t.warnings {
eprintln!("warning: {}: {w}", t.name);
}
if let Some(e) = &t.last_error {
eprintln!("last error: {}: {e}", t.name);
}
}
if json {
print_json(&templates)?;
} else {
print_table(
&[
"NAME", "GEN", "PAUSED", "PG", "DATABASE", "SIZE", "AGE", "SOURCE",
],
templates
.iter()
.map(|t| {
vec![
t.name.clone(),
format!("{}/{}", t.current_gen, t.generations),
if t.paused { "paused" } else { "-" }.to_string(),
t.pg_major.to_string(),
t.database.clone(),
human_bytes(t.size_bytes),
format_duration(Duration::from_secs(t.age_secs)),
source_cell(&t.source),
]
})
.collect(),
);
}
}
TemplateCmd::History { name, json } => {
validate_name("template", &name)?;
let (template, generations, paused) =
match client::request(home, Request::TemplateHistory { name }).await? {
Response::Generations {
template,
generations,
paused,
} => (template, generations, paused),
other => return Err(unexpected(other)),
};
if json {
print_json(&serde_json::json!({
"template": template,
"paused": paused,
"generations": generations,
}))?;
} else {
if paused {
println!("{}", paused_line(&template));
}
let now = now();
print_table(
&["GEN", "KIND", "BUILT", "PG", "SIZE", "CURRENT"],
generations
.iter()
.map(|g| {
let ago = u64::try_from(now - g.built_at).unwrap_or(0);
vec![
g.gen.to_string(),
serde_json::to_value(g.kind)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default(),
format!("{} ago", format_duration(Duration::from_secs(ago))),
g.pg_major.to_string(),
human_bytes(g.size_bytes),
if g.current { "*" } else { "" }.to_string(),
]
})
.collect(),
);
}
}
TemplateCmd::Rollback { name, to } => {
validate_name("template", &name)?;
match client::request(home, Request::TemplateRollback { name, to }).await? {
Response::TemplateBuilt(t) => println!(
"template {} now uses generation {} (scheduled refreshes paused)",
t.name, t.current_gen
),
other => return Err(unexpected(other)),
}
}
TemplateCmd::Upgrade { name, to, as_name } => {
validate_name("template", &name)?;
if let Some(n) = &as_name {
validate_name("template", n)?;
}
let req = Request::TemplateUpgrade {
name: name.clone(),
to,
as_name,
};
match client::request(home, req).await? {
Response::TemplateBuilt(t) => println!(
"upgraded {name} to Postgres {}: template {} generation {} ({})",
t.pg_major,
t.name,
t.current_gen,
human_bytes(t.size_bytes)
),
other => return Err(unexpected(other)),
}
}
}
Ok(())
}
fn source_cell(source: &TemplateSource) -> String {
const MAX: usize = 48;
let text = match source {
TemplateSource::Url { url } => url.clone(),
TemplateSource::Seed { command, .. } => {
format!(
"seed: {}",
command.split_whitespace().collect::<Vec<_>>().join(" ")
)
}
TemplateSource::Promoted { clone_id } => format!("promoted from {clone_id}"),
};
if text.chars().count() <= MAX {
return text;
}
let cut: String = text.chars().take(MAX - 1).collect();
format!("{cut}…")
}
fn paused_line(name: &str) -> String {
format!(
"paused: automatic builds stopped — run \"mitosis template refresh {name}\" or \"mitosis template set {name} --resume\""
)
}
fn print_built(t: &TemplateInfo) {
println!(
"template {} built (Postgres {}, {})",
t.name,
t.pg_major,
human_bytes(t.size_bytes)
);
}
fn print_clones(clones: &[CloneInfo]) {
let now = now();
let secs = |s: i64| format_duration(Duration::from_secs(u64::try_from(s).unwrap_or(0)));
let opt_bytes = |b: Option<u64>| b.map_or("-".to_string(), human_bytes);
print_table(
&[
"ID",
"NAME",
"TEMPLATE",
"PORT",
"AGE",
"TTL LEFT",
"RAM",
"EXTRA DISK",
"FLAGS",
],
clones
.iter()
.map(|c| {
vec![
c.id.clone(),
c.name.clone().unwrap_or_else(|| "-".into()),
c.template.clone(),
c.port.to_string(),
secs(now - c.created_at),
if c.protected {
"never".into()
} else {
secs(c.expires_at - now)
},
opt_bytes(c.rss_bytes),
opt_bytes(c.own_disk_bytes),
flags(c),
]
})
.collect(),
);
}
fn flags(c: &CloneInfo) -> String {
let mut f = Vec::new();
if c.disk_warn {
f.push("!");
}
if c.protected {
f.push("protected");
}
f.join(" ")
}
struct RawTerminal {
saved: nix::sys::termios::Termios,
}
impl RawTerminal {
fn enter() -> Result<RawTerminal> {
use nix::sys::termios::{
tcgetattr, tcsetattr, LocalFlags, SetArg, SpecialCharacterIndices as C,
};
let stdin = std::io::stdin();
let saved = tcgetattr(&stdin).map_err(|_| {
MitosisError::new(ErrorCode::InvalidRequest, "mitosis top needs a terminal")
.with_hint("run it in a terminal, or use `mitosis list --json`")
})?;
let mut raw = saved.clone();
raw.local_flags
.remove(LocalFlags::ICANON | LocalFlags::ECHO | LocalFlags::ISIG);
raw.control_chars[C::VMIN as usize] = 0;
raw.control_chars[C::VTIME as usize] = 20;
tcsetattr(&stdin, SetArg::TCSANOW, &raw)
.map_err(|e| MitosisError::new(ErrorCode::Internal, format!("terminal: {e}")))?;
let term = RawTerminal { saved };
print!("\x1b[?1049h\x1b[?25l");
let _ = std::io::Write::flush(&mut std::io::stdout());
Ok(term)
}
}
impl Drop for RawTerminal {
fn drop(&mut self) {
let _ = nix::sys::termios::tcsetattr(
std::io::stdin(),
nix::sys::termios::SetArg::TCSANOW,
&self.saved,
);
print!("\x1b[?25h\x1b[?1049l");
let _ = std::io::Write::flush(&mut std::io::stdout());
}
}
fn quit_key() -> bool {
use std::io::Read;
let mut b = [0u8; 1];
matches!(std::io::stdin().read(&mut b), Ok(1) if matches!(b[0], b'q' | b'Q' | 3))
}
fn draw_top(u: &budget::Usage) {
let now = now();
let secs = |s: i64| format_duration(Duration::from_secs(u64::try_from(s).unwrap_or(0)));
let opt_bytes = |b: Option<u64>| b.map_or("-".to_string(), human_bytes);
let of = |used: String, cap: Option<u64>| match cap {
Some(c) => format!("{used} / {}", human_bytes(c)),
None => format!("{used} / no limit"),
};
print!("\x1b[H\x1b[2J");
println!("mitosis top q to quit, refreshes every 2s\n");
print_table(
&[
"ID",
"NAME",
"TEMPLATE",
"TTL LEFT",
"RAM",
"EXTRA DISK",
"FLAGS",
],
u.clones
.iter()
.map(|c| {
vec![
c.id.clone(),
c.name.clone().unwrap_or_else(|| "-".into()),
c.template.clone(),
if c.protected {
"never".into()
} else {
secs(c.expires_at - now)
},
opt_bytes(c.rss_bytes),
opt_bytes(c.own_disk_bytes),
[(c.disk_warn, "!"), (c.protected, "protected")]
.iter()
.filter(|(on, _)| *on)
.map(|(_, t)| *t)
.collect::<Vec<_>>()
.join(" "),
]
})
.collect(),
);
println!(
"\nclones {} / {}\nram {}\ndisk {}\nfree {}\nqueued {}",
u.clones.len(),
u.max_clones,
of(human_bytes(u.ram_bytes), u.ram_budget),
of(human_bytes(u.disk_bytes), u.disk_budget),
human_bytes(u.free_disk_bytes),
u.queued
);
let _ = std::io::Write::flush(&mut std::io::stdout());
}
async fn top(home: &Home) -> Result<()> {
let _terminal = RawTerminal::enter()?;
loop {
match client::request(home, Request::Usage).await? {
Response::Usage(u) => draw_top(&u),
other => return Err(unexpected(other)),
}
let quit = tokio::task::spawn_blocking(quit_key)
.await
.map_err(|e| MitosisError::new(ErrorCode::Internal, format!("terminal: {e}")))?;
if quit {
return Ok(());
}
}
}
fn print_table(headers: &[&str], rows: Vec<Vec<String>>) {
let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
for row in &rows {
for (w, cell) in widths.iter_mut().zip(row) {
*w = (*w).max(cell.chars().count());
}
}
let line = |cells: Vec<&str>| {
let parts: Vec<String> = cells
.iter()
.zip(&widths)
.map(|(c, w)| format!("{c:<w$}"))
.collect();
println!("{}", parts.join(" ").trim_end());
};
line(headers.to_vec());
for row in &rows {
line(row.iter().map(String::as_str).collect());
}
}
fn human_bytes(b: u64) -> String {
if b < 1000 {
return format!("{b} B");
}
let mut v = b as f64;
let mut unit = "B";
for u in ["kB", "MB", "GB", "TB", "PB"] {
if v < 1000.0 {
break;
}
v /= 1000.0;
unit = u;
}
if v < 10.0 {
format!("{v:.1} {unit}")
} else {
format!("{v:.0} {unit}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn clap_definition_is_valid() {
Cli::command().debug_assert();
}
#[test]
fn sizes() {
assert_eq!(human_bytes(0), "0 B");
assert_eq!(human_bytes(999), "999 B");
assert_eq!(human_bytes(3_400), "3.4 kB");
assert_eq!(human_bytes(34_000_000), "34 MB");
assert_eq!(human_bytes(1_200_000_000), "1.2 GB");
}
#[test]
fn source_cells_are_short() {
let url = TemplateSource::Url {
url: "postgres://u:***@h/db".into(),
};
assert_eq!(source_cell(&url), "postgres://u:***@h/db");
let seed = TemplateSource::Seed {
command: format!("psql \"$DATABASE_URL\"\n -f {}", "x".repeat(80)),
cwd: None,
path: None,
};
let cell = source_cell(&seed);
assert!(
cell.starts_with("seed: psql \"$DATABASE_URL\" -f xx"),
"{cell}"
);
assert_eq!(cell.chars().count(), 48);
assert!(cell.ends_with('…') && !cell.contains('\n'));
}
#[test]
fn init_needs_exactly_one_source() {
assert!(Cli::try_parse_from(["mitosis", "template", "init"]).is_err());
assert!(Cli::try_parse_from([
"mitosis",
"template",
"init",
"--seed",
"x",
"--from-url",
"y"
])
.is_err());
assert!(Cli::try_parse_from(["mitosis", "template", "init", "--seed", "x"]).is_ok());
}
#[test]
fn settings_flags_parse() {
let cli = Cli::try_parse_from([
"mitosis",
"template",
"set",
"main",
"--keep",
"2",
"--every",
"off",
"--mode",
"layered",
"--migrate",
"make migrate",
"--auto-migrate",
"on",
"--on-stale",
"refresh",
"--max-age",
"1d",
"--resume",
])
.unwrap();
let Cmd::Template {
command: TemplateCmd::Set {
settings, resume, ..
},
} = cli.command
else {
panic!("wrong command")
};
assert!(resume);
let (max_age, s) = settings.parse().unwrap();
assert_eq!(max_age, Some(86_400));
assert_eq!(s.keep, Some(2));
assert!(s.every_off && s.every_secs.is_none());
assert_eq!(s.every_mode, Some(crate::protocol::RefreshMode::Layered));
assert_eq!(s.migrate.unwrap().command, "make migrate");
assert_eq!(s.auto_migrate, Some(true));
assert_eq!(s.on_stale, Some(crate::protocol::OnStale::Refresh));
assert!(Cli::try_parse_from(["mitosis", "template", "set", "--mode", "fast"]).is_err());
}
}