use std::path::{Path, PathBuf};
use std::sync::Arc;
use clap::Args;
use crate::bundled::{AgentAction, BundledAgent, install_bundled, plan_agent_actions};
use crate::config::Config;
const INSTALL_URL: &str = "https://leviath.dev/install.sh";
pub const UPDATE_LONG_ABOUT: &str = "\
Update Leviath, then offer to bring everything else up to date with it.
The binary is updated with the installer that put it there, which is worked out
from where the file is rather than guessed from the version string (every
channel ships the same version number, so the string cannot tell you):
Homebrew a Cellar path names the formula, and the formula names the
channel: `brew upgrade leviath-beta`
Scoop the same, from the package under scoop/apps
cargo says to run `cargo install leviath-cli` and stops. Updating it
means a long compile, which is not something to start unasked
script re-runs the hosted installer for a channel. The install script
keeps no record of the channel it used, so this defaults to
stable - pass --channel to say otherwise
The blueprints and the config are checked every time, whatever the binary step
did. `brew upgrade` on its own leaves both behind, and a binary that was
already current is not a reason to stop looking: an install can be months
behind on its blueprints with a `lev` that needs no update at all.
Nothing is written to your agents directory without a yes. The whole list is
printed first, then one confirmation covers it; --install-agents is how a
script says yes. --yes alone is not enough, because updating a binary and
replacing the blueprints in your agents directory are different requests. A
copy you edited is named as edited and asked about on its own, and no flag
covers it: installing removes the directory and takes your edits with it.
Config migrations are described line by line before anything is written, and
then asked about.
--check and --json report the plan and change nothing. --dry-run walks the
whole flow, prompts and all, and prints what each step would do instead of
doing it.";
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
#[value(rename_all = "lowercase")]
pub enum Channel {
Stable,
Beta,
Alpha,
}
impl Channel {
pub fn id(self) -> &'static str {
match self {
Self::Stable => "stable",
Self::Beta => "beta",
Self::Alpha => "alpha",
}
}
pub fn package(self) -> &'static str {
match self {
Self::Stable => "leviath",
Self::Beta => "leviath-beta",
Self::Alpha => "leviath-alpha",
}
}
pub fn from_package(name: &str) -> Option<Self> {
[Self::Stable, Self::Beta, Self::Alpha]
.into_iter()
.find(|c| c.package() == name)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InstallMethod {
Homebrew {
formula: String,
},
Scoop {
package: String,
},
Cargo,
Script {
channel: Channel,
},
Unknown {
path: PathBuf,
},
}
impl InstallMethod {
pub fn id(&self) -> &'static str {
match self {
Self::Homebrew { .. } => "homebrew",
Self::Scoop { .. } => "scoop",
Self::Cargo => "cargo",
Self::Script { .. } => "script",
Self::Unknown { .. } => "unknown",
}
}
pub fn channel(&self) -> Option<Channel> {
match self {
Self::Homebrew { formula } => Channel::from_package(formula),
Self::Scoop { package } => Channel::from_package(package),
Self::Cargo => Some(Channel::Stable),
Self::Script { channel } => Some(*channel),
Self::Unknown { .. } => None,
}
}
pub fn describe(&self) -> String {
let channel = match self.channel() {
Some(c) => format!(", {} channel", c.id()),
None => String::new(),
};
match self {
Self::Homebrew { formula } => format!("Homebrew (formula {formula}{channel})"),
Self::Scoop { package } => format!("Scoop (package {package}{channel})"),
Self::Cargo => format!("cargo install (crates.io{channel})"),
Self::Script { .. } => format!("the install script ({INSTALL_URL}{channel})"),
Self::Unknown { path } => {
format!("something else - the binary is at {}", path.display())
}
}
}
}
fn component_after(path: &Path, marker: &str) -> Option<String> {
let mut components = path
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned());
components.by_ref().find(|c| c == marker)?;
components.next()
}
fn has_component(path: &Path, marker: &str) -> bool {
path.components()
.any(|c| c.as_os_str().to_string_lossy().eq_ignore_ascii_case(marker))
}
const UNAMBIGUOUS_BREW_PREFIXES: &[&str] = &["/opt/homebrew", "/home/linuxbrew/.linuxbrew"];
const SCRIPT_DESTINATIONS: &[&str] = &["/usr/local/bin", "/usr/bin"];
fn script_destinations(home: Option<&Path>) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = SCRIPT_DESTINATIONS.iter().map(PathBuf::from).collect();
if let Some(home) = home {
dirs.push(home.join(".local").join("bin"));
dirs.push(
home.join("AppData")
.join("Local")
.join("Leviath")
.join("bin"),
);
}
dirs
}
pub fn detect(
exe: &Path,
home: Option<&Path>,
brew_prefix: Option<&Path>,
requested: Option<Channel>,
) -> InstallMethod {
let channel = requested.unwrap_or(Channel::Stable);
if let Some(formula) = component_after(exe, "Cellar") {
return InstallMethod::Homebrew { formula };
}
let under_brew = UNAMBIGUOUS_BREW_PREFIXES.iter().any(|p| exe.starts_with(p))
|| brew_prefix.is_some_and(|p| exe.starts_with(p) && !is_ambiguous_prefix(p));
if under_brew {
return InstallMethod::Homebrew {
formula: channel.package().to_string(),
};
}
if has_component(exe, "scoop") {
let package = component_after(exe, "apps").unwrap_or_else(|| channel.package().to_string());
return InstallMethod::Scoop { package };
}
let cargo_bin = home.map(|h| h.join(".cargo").join("bin"));
if cargo_bin.is_some_and(|dir| exe.starts_with(dir)) {
return InstallMethod::Cargo;
}
let parent = exe.parent();
let script_dir = script_destinations(home)
.iter()
.any(|d| parent == Some(d.as_path()));
match script_dir {
true => InstallMethod::Script { channel },
false => InstallMethod::Unknown {
path: exe.to_path_buf(),
},
}
}
fn is_ambiguous_prefix(prefix: &Path) -> bool {
matches!(
prefix.to_string_lossy().trim_end_matches('/'),
"/usr/local" | "/usr" | "" | "/"
)
}
pub struct Migration {
pub name: &'static str,
pub description: &'static str,
pub applies: fn(&Config, &toml::Table) -> bool,
pub apply: fn(&mut Config) -> Vec<String>,
}
pub const MIGRATIONS: &[Migration] = &[];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BinaryStep {
Run(Vec<String>),
Advise(String),
}
pub struct UpdatePlan {
pub method: InstallMethod,
pub binary: BinaryStep,
pub agents: Vec<(&'static BundledAgent, AgentAction)>,
pub migrations: Vec<&'static Migration>,
pub config: ConfigState,
}
pub enum ConfigState {
Loaded(Box<Config>),
Unreadable(String),
}
pub fn binary_step(method: &InstallMethod) -> BinaryStep {
match method {
InstallMethod::Homebrew { formula } => BinaryStep::Run(vec![
"brew".to_string(),
"upgrade".to_string(),
formula.clone(),
]),
InstallMethod::Scoop { package } => BinaryStep::Run(vec![
"scoop".to_string(),
"update".to_string(),
package.clone(),
]),
InstallMethod::Cargo => BinaryStep::Advise(
"this copy was built by `cargo install`. Update it with \
`cargo install leviath-cli` - that is a full compile, so it is not \
something to start for you."
.to_string(),
),
InstallMethod::Script { channel } => BinaryStep::Run(vec![
"sh".to_string(),
"-c".to_string(),
format!(
"curl -fsSL {INSTALL_URL} | sh -s -- --channel {}",
channel.id()
),
]),
InstallMethod::Unknown { path } => BinaryStep::Advise(format!(
"`lev` is at {}, which is not where any installer Leviath ships puts it. \
Update it the way you installed it, or re-install with \
`curl -fsSL {INSTALL_URL} | sh`.",
path.display()
)),
}
}
struct LoadedConfig {
config: Config,
raw: toml::Table,
}
fn load_config(path: &Path) -> anyhow::Result<LoadedConfig> {
let config = Config::load_from_path_public(path)?;
let raw = match std::fs::read_to_string(path) {
Ok(text) => toml::from_str::<toml::Table>(&text).expect("the config parsed a moment ago"),
Err(_) => toml::Table::new(),
};
Ok(LoadedConfig { config, raw })
}
pub fn plan(args: &UpdateArgs, env: &UpdateEnv) -> UpdatePlan {
let method = detect(
&env.exe,
env.home.as_deref(),
env.brew_prefix.as_deref(),
args.channel,
);
let binary = binary_step(&method);
let agents = plan_agent_actions(&env.agents_dir);
let (migrations, config) = match load_config(&env.config_path) {
Ok(loaded) => (
env.migrations
.iter()
.filter(|m| (m.applies)(&loaded.config, &loaded.raw))
.collect(),
ConfigState::Loaded(Box::new(loaded.config)),
),
Err(e) => (Vec::new(), ConfigState::Unreadable(e.to_string())),
};
UpdatePlan {
method,
binary,
agents,
migrations,
config,
}
}
fn changing(plan: &UpdatePlan) -> Vec<&(&'static BundledAgent, AgentAction)> {
plan.agents.iter().filter(|(_, a)| a.is_change()).collect()
}
pub fn format_plan(plan: &UpdatePlan, version: &str) -> String {
let mut out = format!(
"\nlev {version}, installed with {}\n\n",
plan.method.describe()
);
match &plan.binary {
BinaryStep::Run(argv) => out.push_str(&format!(" binary {}\n", argv.join(" "))),
BinaryStep::Advise(text) => out.push_str(&format!(" binary {text}\n")),
}
let changes = changing(plan);
match changes.is_empty() {
true => out.push_str(&format!(
" agents all {} bundled blueprints are up to date\n",
plan.agents.len()
)),
false => {
out.push_str(&format!(
" agents {} of {} would change\n",
changes.len(),
plan.agents.len()
));
for (agent, action) in &changes {
out.push_str(&format!(
" {} - {}\n",
agent.name,
action.label(agent.version)
));
}
}
}
match (&plan.config, plan.migrations.is_empty()) {
(ConfigState::Unreadable(e), _) => {
out.push_str(&format!(" config could not be read: {e}\n"))
}
(ConfigState::Loaded(_), true) => out.push_str(" config nothing to migrate\n"),
(ConfigState::Loaded(_), false) => {
out.push_str(&format!(
" config {} migration(s)\n",
plan.migrations.len()
));
for migration in &plan.migrations {
out.push_str(&format!(
" {} - {}\n",
migration.name, migration.description
));
}
}
}
out
}
pub fn plan_json(plan: &UpdatePlan, version: &str) -> serde_json::Value {
let binary = match &plan.binary {
BinaryStep::Run(argv) => serde_json::json!({ "action": "run", "command": argv }),
BinaryStep::Advise(text) => serde_json::json!({ "action": "advise", "message": text }),
};
let agents: Vec<serde_json::Value> = plan
.agents
.iter()
.map(|(agent, action)| {
serde_json::json!({
"name": agent.name,
"version": agent.version,
"change": action.label(agent.version),
"changes": action.is_change(),
"preselected": action.preselect(),
})
})
.collect();
let migrations: Vec<serde_json::Value> = plan
.migrations
.iter()
.map(|m| serde_json::json!({ "name": m.name, "description": m.description }))
.collect();
serde_json::json!({
"version": version,
"install_method": plan.method.id(),
"channel": plan.method.channel().map(Channel::id),
"binary": binary,
"agents": agents,
"migrations": migrations,
"config_error": match &plan.config {
ConfigState::Unreadable(e) => serde_json::Value::String(e.clone()),
ConfigState::Loaded(_) => serde_json::Value::Null,
},
})
}
#[derive(Args, Debug, Clone, Default)]
pub struct UpdateArgs {
#[arg(long)]
pub check: bool,
#[arg(long)]
pub yes: bool,
#[arg(long)]
pub install_agents: bool,
#[arg(long, value_name = "CHANNEL")]
pub channel: Option<Channel>,
#[arg(long)]
pub dry_run: bool,
#[arg(long)]
pub json: bool,
}
pub type CommandRunner = Arc<dyn Fn(&[String]) -> anyhow::Result<()> + Send + Sync>;
pub type Confirm = Arc<dyn Fn(&str) -> bool + Send + Sync>;
pub struct UpdateEnv {
pub exe: PathBuf,
pub home: Option<PathBuf>,
pub brew_prefix: Option<PathBuf>,
pub agents_dir: PathBuf,
pub config_path: PathBuf,
pub runner: CommandRunner,
pub confirm: Confirm,
pub migrations: &'static [Migration],
}
fn agreed(args: &UpdateArgs, env: &UpdateEnv, question: &str) -> bool {
args.yes || (env.confirm)(question)
}
fn update_binary(args: &UpdateArgs, env: &UpdateEnv, plan: &UpdatePlan) -> anyhow::Result<()> {
let argv = match &plan.binary {
BinaryStep::Advise(text) => {
println!(" {text}");
return Ok(());
}
BinaryStep::Run(argv) => argv,
};
let shown = argv.join(" ");
if !agreed(args, env, &format!("Run `{shown}`?")) {
println!(" left the binary alone");
return Ok(());
}
if args.dry_run {
println!(" would run: {shown}");
return Ok(());
}
(env.runner)(argv)
}
fn update_agents(args: &UpdateArgs, env: &UpdateEnv, plan: &UpdatePlan) {
let changes = changing(plan);
if changes.is_empty() {
println!(" every bundled blueprint is already up to date");
return;
}
for (agent, action) in &changes {
println!(" {} - {}", agent.name, action.label(agent.version));
}
let clean = changes.iter().filter(|(_, a)| a.preselect()).count();
let edited = changes.len() - clean;
if edited > 0 {
println!(
" {edited} of these you have edited locally. Installing removes the directory \
first, so your edits and any file you added go with it - each is asked about \
on its own."
);
}
let install_clean = match clean {
0 => false,
n => args.install_agents || (env.confirm)(&format!("Install these {n} blueprint(s)?")),
};
for (agent, action) in changes {
let ok = match action.preselect() {
true => install_clean,
false => (env.confirm)(&format!(
"{} - {}. Overwrite your edited copy?",
agent.name,
action.label(agent.version)
)),
};
if !ok {
println!(" skipped {}", agent.name);
continue;
}
if args.dry_run {
println!(" would install {} {}", agent.name, agent.version);
continue;
}
match install_bundled(agent, &env.agents_dir) {
Ok(()) => println!(" installed {} {}", agent.name, agent.version),
Err(e) => println!(" could not install {}: {e}", agent.name),
}
}
}
fn migrate_config(args: &UpdateArgs, env: &UpdateEnv, plan: &UpdatePlan) -> anyhow::Result<()> {
let config = match &plan.config {
ConfigState::Unreadable(e) => {
println!(" the config could not be read, so it was left alone: {e}");
return Ok(());
}
ConfigState::Loaded(config) => config,
};
if plan.migrations.is_empty() {
println!(" the config needs no changes");
return Ok(());
}
let mut config = config.as_ref().clone();
let mut changed = Vec::new();
for migration in &plan.migrations {
for line in (migration.apply)(&mut config) {
changed.push(format!("{}: {line}", migration.name));
}
}
for line in &changed {
println!(" - {line}");
}
let path = env.config_path.display();
if !agreed(args, env, &format!("Write these changes to {path}?")) {
println!(" config left as it is");
return Ok(());
}
if args.dry_run {
println!(" would write {path}");
return Ok(());
}
config.save_to_path_public(&env.config_path)?;
println!(" wrote {path}");
Ok(())
}
pub fn execute_with(args: &UpdateArgs, env: &UpdateEnv, version: &str) -> anyhow::Result<()> {
let plan = plan(args, env);
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&plan_json(&plan, version))
.expect("a plan is plain data and always serializes")
);
return Ok(());
}
print!("{}", format_plan(&plan, version));
if args.check {
return Ok(());
}
println!("\nbinary");
update_binary(args, env, &plan)?;
println!("\nblueprints");
update_agents(args, env, &plan);
println!("\nconfig");
migrate_config(args, env, &plan)?;
Ok(())
}
#[cfg(test)]
mod tests;