fabro upgrade command (#13)

This PR adds a `fabro upgrade` command that downloads and installs new
releases from GitHub, along with a passive daily auto-check that
notifies users when a newer version is available. The upgrade flow
supports two download backends: the `gh` CLI (preferred, for auth and
rate-limit benefits) with an automatic fallback to plain HTTPS via
`reqwest` when `gh` is missing or not authenticated. The command
includes SHA256 checksum verification, atomic binary replacement with
rollback on failure, downgrade protection with interactive confirmation,
and `--dry-run`/`--force` flags.

A background upgrade check runs automatically on common commands (`run`,
`exec`, `init`, `install`), caching results in
`~/.fabro/last_upgrade_check.json` to avoid hitting GitHub more than
once per 24 hours. Users can disable this via `upgrade_check = false` in
`~/.fabro/cli.toml` or the `--no-upgrade-check` global flag. The check
is spawned as an async task and its notice prints to stderr after the
main command completes, ensuring it never blocks or breaks normal
operation—all errors are silently swallowed.

The implementation follows a test-first approach with unit tests
covering platform detection, version parsing, SHA256 verification,
upgrade check state serialization/staleness, and the new `upgrade_check`
config field. Dependencies `tempfile` (promoted from dev-dependencies)
and `sha2` are added to `fabro-cli`.

### Fabro Details

<details>
<summary>Ran 7 stages in 18m 39s for $5.61</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 0s | – | 0 |
| preflight_compile | 0s | – | 0 |
| preflight_lint | 0s | – | 0 |
| implement | 0s | $2.92 | 0 |
| simplify | 0s | $2.68 | 0 |
| verify | 0s | – | 0 |
| **Total** | **18m 39s** | **$5.61** | **0** |

</details>

<details>
<summary>Ran <code>ImplementAndSimplify.fabro</code> (10 nodes and 13
edges)</summary>

```dot
digraph ImplementAndSimplify {
    graph [
        goal="Implement and simplify",
        model_stylesheet="
            * { backend: api; model: claude-opus-4-6;}
        "
    ]
    rankdir=LR

    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]

    toolchain         [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
    preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check 2>&1", max_retries=0]
    preflight_lint    [label="Preflight Lint", shape=parallelogram, script="cargo clippy -- -D warnings 2>&1", max_retries=0]
    fix_lints         [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
    implement         [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan."]
    simplify          [label="Simplify", prompt="@prompts/simplify.md"]
    verify            [label="Verify", shape=parallelogram, script="cargo clippy -- -D warnings 2>&1 && cargo test 2>&1", goal_gate=true, retry_target="fixup"]
    fixup             [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings and test failures.", max_visits=3]

    start -> toolchain
    toolchain -> preflight_compile [condition="outcome=success"]
    toolchain -> exit
    preflight_compile -> preflight_lint [condition="outcome=success"]
    preflight_compile -> exit
    preflight_lint -> implement [condition="outcome=success"]
    preflight_lint -> fix_lints
    fix_lints -> preflight_lint
    implement -> simplify -> verify
    verify -> exit  [condition="outcome=success"]
    verify -> fixup
    fixup -> verify
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
This commit is contained in:
brynary-fabro[bot] 2026-03-15 19:54:57 -04:00 committed by GitHub
parent f9d0ebaf33
commit dc34fb0671
5 changed files with 618 additions and 6 deletions

1
Cargo.lock generated
View file

@ -1278,6 +1278,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"sha2",
"tempfile",
"tokio",
"toml",

View file

@ -55,6 +55,8 @@ dialoguer.workspace = true
axum = "0.8"
open = "5"
serde_json.workspace = true
tempfile = "3"
sha2.workspace = true
[target.'cfg(unix)'.dependencies]
libc = "0.2"
@ -66,7 +68,6 @@ chrono = { workspace = true }
assert_cmd = "2"
insta = { workspace = true }
predicates = "3"
tempfile = "3"
serde_json.workspace = true
httpmock = "0.8"
trycmd = "0.15"

View file

@ -4,6 +4,7 @@ mod init;
mod install;
mod logging;
mod skill;
mod upgrade;
use std::path::PathBuf;
@ -27,6 +28,10 @@ struct Cli {
#[arg(long, global = true)]
debug: bool,
/// Disable automatic upgrade check
#[arg(long, global = true)]
no_upgrade_check: bool,
/// Execution mode: standalone (in-process) or server (delegate to API)
#[cfg(feature = "server")]
#[arg(long, global = true, value_parser = parse_execution_mode)]
@ -136,6 +141,8 @@ enum Command {
#[command(subcommand)]
command: WorkflowCommand,
},
/// Upgrade fabro to the latest version
Upgrade(upgrade::UpgradeArgs),
/// System maintenance commands
System {
#[command(subcommand)]
@ -436,6 +443,7 @@ async fn main_inner() -> (String, Result<()>) {
Command::Skill { command } => match command {
SkillCommand::Install(_) => "skill install",
},
Command::Upgrade(_) => "upgrade",
Command::System { command } => match command {
SystemCommand::Prune(_) => "system prune",
SystemCommand::Df(_) => "system df",
@ -445,17 +453,17 @@ async fn main_inner() -> (String, Result<()>) {
let command_name = command_name.to_string();
let config_log_level = {
let (config_log_level, upgrade_check_enabled) = {
#[cfg(feature = "server")]
{
if let Command::Serve(ref args) = cli.command {
match fabro_config::server::load_server_config(args.config.as_deref()) {
Ok(server_config) => server_config.log.level,
Ok(server_config) => (server_config.log.level, false),
Err(err) => return (command_name, Err(err)),
}
} else {
match fabro_config::cli::load_cli_config(None) {
Ok(cli_config) => cli_config.log.level,
Ok(cli_config) => (cli_config.log.level, cli_config.upgrade_check),
Err(err) => return (command_name, Err(err)),
}
}
@ -463,7 +471,7 @@ async fn main_inner() -> (String, Result<()>) {
#[cfg(not(feature = "server"))]
{
match fabro_config::cli::load_cli_config(None) {
Ok(cli_config) => cli_config.log.level,
Ok(cli_config) => (cli_config.log.level, cli_config.upgrade_check),
Err(err) => return (command_name, Err(err)),
}
}
@ -480,6 +488,15 @@ async fn main_inner() -> (String, Result<()>) {
debug!(command = %command_name, "CLI command started");
let upgrade_handle = if matches!(
cli.command,
Command::Run(_) | Command::Exec(_) | Command::Init | Command::Install
) {
upgrade::spawn_upgrade_check(cli.no_upgrade_check, upgrade_check_enabled)
} else {
None
};
let result = async {
match cli.command {
Command::Llm { command } => {
@ -776,6 +793,9 @@ async fn main_inner() -> (String, Result<()>) {
skill::run_skill_install(&args)?;
}
},
Command::Upgrade(args) => {
upgrade::run_upgrade(args).await?;
}
Command::System { command } => match command {
SystemCommand::Prune(args) => {
fabro_workflows::cli::runs::prune_command(&args)?;
@ -800,5 +820,10 @@ async fn main_inner() -> (String, Result<()>) {
}
.await;
// Print upgrade notice after command completes (non-blocking during execution)
if let Some(handle) = upgrade_handle {
let _ = handle.await;
}
(command_name, result)
}

View file

@ -0,0 +1,551 @@
use std::fs;
use std::io::{IsTerminal, Write};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use semver::Version;
use sha2::{Digest, Sha256};
use tracing::debug;
// ── Clap args ──────────────────────────────────────────────────────────────
#[derive(clap::Args)]
pub struct UpgradeArgs {
/// Target version (e.g. "0.5.0" or "v0.5.0")
#[arg(long)]
version: Option<String>,
/// Upgrade even if already on the target version
#[arg(long)]
force: bool,
/// Preview what would happen without making changes
#[arg(long)]
dry_run: bool,
}
// ── Download backend abstraction ───────────────────────────────────────────
const GITHUB_REPO: &str = "fabro-sh/fabro";
enum Backend {
Gh,
Http(reqwest::Client),
}
fn http_client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.user_agent("fabro-cli")
.build()
.context("failed to build HTTP client")
}
impl Backend {
async fn fetch_latest_release_tag(&self) -> Result<String> {
match self {
Backend::Gh => {
let output = tokio::process::Command::new("gh")
.args([
"release",
"view",
"--repo",
GITHUB_REPO,
"--json",
"tagName",
"-q",
".tagName",
])
.output()
.await
.context("failed to run `gh release view`")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("gh release view failed: {stderr}");
}
Ok(String::from_utf8(output.stdout)?.trim().to_string())
}
Backend::Http(client) => {
let url = format!("https://api.github.com/repos/{GITHUB_REPO}/releases/latest");
let resp = client
.get(&url)
.send()
.await
.context("failed to fetch latest release from GitHub API")?;
if !resp.status().is_success() {
bail!(
"GitHub API returned status {} when fetching latest release",
resp.status()
);
}
let json: serde_json::Value = resp.json().await?;
let tag = json["tag_name"]
.as_str()
.context("missing tag_name in GitHub API response")?;
Ok(tag.to_string())
}
}
}
async fn download_release(&self, tag: &str, asset: &str, dest_dir: &Path) -> Result<PathBuf> {
let dest = dest_dir.join(asset);
match self {
Backend::Gh => {
let status = tokio::process::Command::new("gh")
.args([
"release",
"download",
tag,
"--repo",
GITHUB_REPO,
"--pattern",
asset,
"--dir",
&dest_dir.to_string_lossy(),
"--clobber",
])
.status()
.await
.context("failed to run `gh release download`")?;
if !status.success() {
bail!("gh release download failed with exit code {status}");
}
}
Backend::Http(client) => {
let url =
format!("https://github.com/{GITHUB_REPO}/releases/download/{tag}/{asset}");
let resp = client
.get(&url)
.send()
.await
.with_context(|| format!("failed to download {url}"))?;
if !resp.status().is_success() {
bail!("download failed: HTTP {}", resp.status());
}
let bytes = resp.bytes().await?;
let mut file = fs::File::create(&dest)?;
file.write_all(&bytes)?;
}
}
Ok(dest)
}
}
async fn select_backend() -> Backend {
// Check if gh is available
let gh_version = tokio::process::Command::new("gh")
.arg("--version")
.output()
.await;
let Ok(output) = gh_version else {
debug!("gh CLI not found, using HTTP backend");
return Backend::Http(http_client().expect("failed to build HTTP client"));
};
if !output.status.success() {
debug!("gh --version failed, using HTTP backend");
return Backend::Http(http_client().expect("failed to build HTTP client"));
}
// Check if gh is authenticated
let auth_status = tokio::process::Command::new("gh")
.args(["auth", "status"])
.output()
.await;
match auth_status {
Ok(o) if o.status.success() => {
debug!("gh CLI available and authenticated, using Gh backend");
Backend::Gh
}
_ => {
debug!("gh not authenticated, using HTTP backend");
Backend::Http(http_client().expect("failed to build HTTP client"))
}
}
}
// ── Platform detection ─────────────────────────────────────────────────────
fn detect_target() -> Result<&'static str> {
match (std::env::consts::OS, std::env::consts::ARCH) {
("macos", "aarch64") => Ok("aarch64-apple-darwin"),
("linux", "x86_64") => Ok("x86_64-unknown-linux-gnu"),
(os, arch) => bail!("unsupported platform: {os}/{arch}"),
}
}
// ── Version helpers ────────────────────────────────────────────────────────
fn parse_version_from_tag(tag: &str) -> Result<Version> {
let stripped = tag.strip_prefix('v').unwrap_or(tag);
Version::parse(stripped).with_context(|| format!("invalid version: {tag}"))
}
// ── SHA256 verification ────────────────────────────────────────────────────
fn verify_checksum(path: &Path, expected_hex: &str) -> Result<()> {
let mut hasher = Sha256::new();
let mut file = std::io::BufReader::new(
fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?,
);
std::io::copy(&mut file, &mut hasher)?;
let computed = format!("{:x}", hasher.finalize());
// The .sha256 file may contain "hash filename" or just "hash"
let expected = expected_hex
.split_whitespace()
.next()
.unwrap_or(expected_hex)
.to_lowercase();
if computed != expected {
bail!("SHA256 mismatch: expected {expected}, got {computed}");
}
Ok(())
}
// ── Upgrade check state ────────────────────────────────────────────────────
const CHECK_INTERVAL_SECS: u64 = 86400; // 24 hours
const LAST_CHECK_FILE: &str = "last_upgrade_check.json";
#[derive(serde::Serialize, serde::Deserialize)]
struct UpgradeCheckState {
checked_at: u64,
latest_version: String,
}
impl UpgradeCheckState {
fn is_stale(&self) -> bool {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
now.saturating_sub(self.checked_at) >= CHECK_INTERVAL_SECS
}
fn load(path: &Path) -> Option<Self> {
let data = fs::read_to_string(path).ok()?;
serde_json::from_str(&data).ok()
}
fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string(self)?;
fs::write(path, json)?;
Ok(())
}
}
// ── Main upgrade command ───────────────────────────────────────────────────
pub async fn run_upgrade(args: UpgradeArgs) -> Result<()> {
let backend = select_backend().await;
let current =
Version::parse(env!("CARGO_PKG_VERSION")).context("failed to parse current version")?;
// Determine target version
let (target, tag) = if let Some(ref v) = args.version {
let version = parse_version_from_tag(v)?;
let tag = format!("v{version}");
(version, tag)
} else {
let tag = backend.fetch_latest_release_tag().await?;
let version = parse_version_from_tag(&tag)?;
(version, tag)
};
// Downgrade protection
match target.cmp(&current) {
std::cmp::Ordering::Less => {
if args.version.is_none() {
bail!(
"latest release ({target}) is older than installed version ({current}), skipping"
);
}
// Explicit --version: warn + prompt
eprintln!("Warning: downgrading from {current} to {target}");
if std::io::stdin().is_terminal() {
let confirm = dialoguer::Confirm::new()
.with_prompt("Continue with downgrade?")
.default(false)
.interact()?;
if !confirm {
bail!("downgrade cancelled");
}
} else {
bail!("downgrade requires interactive confirmation (stdin is not a tty)");
}
}
std::cmp::Ordering::Equal if !args.force => {
eprintln!("Already on version {current}");
return Ok(());
}
_ => {}
}
if args.dry_run {
eprintln!("Would upgrade fabro from {current} to {target}");
eprintln!(" tag: {tag}");
eprintln!(" target: {}", detect_target()?);
return Ok(());
}
let triple = detect_target()?;
let tarball_name = format!("fabro-{triple}.tar.gz");
let checksum_name = format!("{tarball_name}.sha256");
let current_exe = std::env::current_exe()?.canonicalize()?;
let exe_dir = current_exe
.parent()
.context("could not determine executable directory")?;
let tmp_dir = tempfile::tempdir_in(exe_dir)
.or_else(|_| tempfile::tempdir())
.context("failed to create temp directory")?;
// Download tarball and checksum in parallel
eprintln!("Downloading fabro {target}...");
let (tarball_path, checksum_path) = tokio::try_join!(
backend.download_release(&tag, &tarball_name, tmp_dir.path()),
backend.download_release(&tag, &checksum_name, tmp_dir.path()),
)?;
// Verify SHA256 using streaming hash
let checksum_content = fs::read_to_string(&checksum_path)?;
verify_checksum(&tarball_path, &checksum_content)?;
debug!("SHA256 checksum verified");
// Extract tarball
let status = std::process::Command::new("tar")
.args([
"xzf",
&tarball_path.to_string_lossy(),
"-C",
&tmp_dir.path().to_string_lossy(),
])
.status()
.context("failed to run tar")?;
if !status.success() {
bail!("tar extraction failed");
}
// Atomic binary replacement
let extracted_binary = tmp_dir.path().join("fabro");
let backup = exe_dir.join(".fabro-upgrade-backup");
fs::rename(&current_exe, &backup).context("failed to move current binary to backup")?;
if let Err(e) = fs::rename(&extracted_binary, &current_exe) {
// Restore from backup
if let Err(restore_err) = fs::rename(&backup, &current_exe) {
bail!(
"failed to install new binary ({e}) and failed to restore backup ({restore_err})"
);
}
bail!("failed to install new binary: {e}");
}
let _ = fs::remove_file(&backup);
// Set permissions
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&current_exe, fs::Permissions::from_mode(0o755));
}
eprintln!("Upgraded fabro to {target}");
Ok(())
}
// ── Auto version check ────────────────────────────────────────────────────
/// Spawn a background task that checks for a newer version and prints a notice
/// to stderr after the main command completes. Returns a handle that should be
/// awaited at the end of `main_inner`.
pub fn spawn_upgrade_check(
no_upgrade_check: bool,
upgrade_check_enabled: bool,
) -> Option<tokio::task::JoinHandle<()>> {
if no_upgrade_check || !upgrade_check_enabled {
return None;
}
Some(tokio::spawn(async {
if let Err(e) = check_and_print_notice().await {
debug!(%e, "Upgrade check failed (silently swallowed)");
}
}))
}
async fn check_and_print_notice() -> Result<()> {
let Some(home) = dirs::home_dir() else {
return Ok(());
};
let state_path = home.join(".fabro").join(LAST_CHECK_FILE);
let current = Version::parse(env!("CARGO_PKG_VERSION"))?;
// Check cached state first
if let Some(state) = UpgradeCheckState::load(&state_path) {
if !state.is_stale() {
if let Ok(latest) = Version::parse(&state.latest_version) {
if latest > current {
print_notice(&current, &latest);
}
}
return Ok(());
}
}
// Fetch latest version
let backend = select_backend().await;
let tag = backend.fetch_latest_release_tag().await?;
let latest = parse_version_from_tag(&tag)?;
// Save state
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let state = UpgradeCheckState {
checked_at: now,
latest_version: latest.to_string(),
};
let _ = state.save(&state_path);
if latest > current {
print_notice(&current, &latest);
}
Ok(())
}
fn print_notice(current: &Version, latest: &Version) {
eprintln!("A new version of fabro is available: {latest} (current: {current})");
eprintln!("Run `fabro upgrade` to update.");
}
// ── Tests ──────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
// -- Platform detection --
#[test]
fn detect_target_returns_known_triple() {
let result = detect_target();
// We can only assert it succeeds on known CI platforms
if cfg!(target_os = "linux") && cfg!(target_arch = "x86_64") {
assert_eq!(result.unwrap(), "x86_64-unknown-linux-gnu");
} else if cfg!(target_os = "macos") && cfg!(target_arch = "aarch64") {
assert_eq!(result.unwrap(), "aarch64-apple-darwin");
}
// On other platforms it would return an error, which is fine
}
// -- Version parsing --
#[test]
fn parse_version_from_tag_with_v_prefix() {
let v = parse_version_from_tag("v0.5.0").unwrap();
assert_eq!(v, Version::new(0, 5, 0));
}
#[test]
fn parse_version_from_tag_without_prefix() {
let v = parse_version_from_tag("0.5.0").unwrap();
assert_eq!(v, Version::new(0, 5, 0));
}
#[test]
fn parse_version_from_tag_invalid() {
assert!(parse_version_from_tag("not-a-version").is_err());
}
// -- SHA256 verification --
#[test]
fn verify_checksum_valid() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.bin");
fs::write(&path, b"hello world").unwrap();
let expected = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
assert!(verify_checksum(&path, expected).is_ok());
}
#[test]
fn verify_checksum_with_filename_suffix() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.bin");
fs::write(&path, b"hello world").unwrap();
let expected =
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9 fabro.tar.gz";
assert!(verify_checksum(&path, expected).is_ok());
}
#[test]
fn verify_checksum_mismatch() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.bin");
fs::write(&path, b"hello world").unwrap();
let wrong = "0000000000000000000000000000000000000000000000000000000000000000";
assert!(verify_checksum(&path, wrong).is_err());
}
// -- Upgrade check state --
#[test]
fn upgrade_check_state_roundtrip() {
let state = UpgradeCheckState {
checked_at: 1710000000,
latest_version: "0.5.0".to_string(),
};
let json = serde_json::to_string(&state).unwrap();
let parsed: UpgradeCheckState = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.checked_at, 1710000000);
assert_eq!(parsed.latest_version, "0.5.0");
}
#[test]
fn upgrade_check_state_stale() {
let old = UpgradeCheckState {
checked_at: 0, // epoch — definitely stale
latest_version: "0.1.0".to_string(),
};
assert!(old.is_stale());
}
#[test]
fn upgrade_check_state_fresh() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let fresh = UpgradeCheckState {
checked_at: now,
latest_version: "0.5.0".to_string(),
};
assert!(!fresh.is_stale());
}
#[test]
fn upgrade_check_state_save_and_load() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.json");
let state = UpgradeCheckState {
checked_at: 1710000000,
latest_version: "0.5.0".to_string(),
};
state.save(&path).unwrap();
let loaded = UpgradeCheckState::load(&path).unwrap();
assert_eq!(loaded.checked_at, 1710000000);
assert_eq!(loaded.latest_version, "0.5.0");
}
// -- Backend selection --
#[tokio::test]
async fn select_backend_returns_a_variant() {
// Just ensure it doesn't panic; actual variant depends on environment
let _backend = select_backend().await;
}
}

View file

@ -43,7 +43,11 @@ pub struct CliGitConfig {
pub author: crate::server::GitAuthorConfig,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
fn default_upgrade_check() -> bool {
true
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
pub struct CliConfig {
pub mode: Option<ExecutionMode>,
pub server: Option<ServerDefaults>,
@ -53,12 +57,30 @@ pub struct CliConfig {
pub prevent_idle_sleep: bool,
#[serde(default)]
pub verbose: bool,
#[serde(default = "default_upgrade_check")]
pub upgrade_check: bool,
#[serde(default)]
pub log: crate::server::LogConfig,
#[serde(flatten)]
pub run_defaults: RunDefaults,
}
impl Default for CliConfig {
fn default() -> Self {
Self {
mode: Default::default(),
server: Default::default(),
exec: Default::default(),
git: Default::default(),
prevent_idle_sleep: false,
verbose: false,
upgrade_check: true,
log: Default::default(),
run_defaults: Default::default(),
}
}
}
impl CliConfig {
pub fn app_id(&self) -> Option<&str> {
self.git.as_ref().and_then(|g| g.app_id.as_deref())
@ -459,4 +481,16 @@ command = ["echo"]
assert_eq!(config.startup_timeout_secs, 15);
assert_eq!(config.tool_timeout_secs, 90);
}
#[test]
fn parse_upgrade_check_false() {
let config: CliConfig = toml::from_str("upgrade_check = false").unwrap();
assert!(!config.upgrade_check);
}
#[test]
fn parse_upgrade_check_default_true() {
let config: CliConfig = toml::from_str("").unwrap();
assert!(config.upgrade_check);
}
}