use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
pub fn normalize_backend_url(url: &str) -> String {
url.trim_end_matches('/').to_string()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContainerRuntime {
Docker,
Podman,
}
#[derive(Debug, Clone)]
pub struct ContainerCli {
command: String,
runtime: ContainerRuntime,
buildx_supports_push: bool,
}
impl ContainerCli {
pub fn from_command(command: impl Into<String>) -> Self {
let command = command.into();
let runtime = detect_runtime(&command);
let buildx_supports_push = detect_buildx_push_support(&command);
Self {
command,
runtime,
buildx_supports_push,
}
}
pub fn command(&self) -> &str {
&self.command
}
pub fn runtime(&self) -> ContainerRuntime {
self.runtime
}
pub fn buildx_supports_push(&self) -> bool {
self.buildx_supports_push
}
}
fn detect_runtime(command: &str) -> ContainerRuntime {
if command_file_name(command) == Some("podman") {
return ContainerRuntime::Podman;
}
probe_runtime(command).unwrap_or(ContainerRuntime::Docker)
}
fn command_file_name(command: &str) -> Option<&str> {
use std::path::Path;
Path::new(command)
.file_name()
.and_then(|name| name.to_str())
}
fn detect_buildx_push_support(command: &str) -> bool {
!command.to_lowercase().contains("podman")
}
fn runtime_from_version_output(stdout: &[u8], stderr: &[u8]) -> ContainerRuntime {
let combined = format!(
"{}\n{}",
String::from_utf8_lossy(stdout),
String::from_utf8_lossy(stderr)
);
if combined.to_lowercase().contains("podman") {
ContainerRuntime::Podman
} else {
ContainerRuntime::Docker
}
}
fn probe_runtime(command: &str) -> Option<ContainerRuntime> {
use std::process::Command;
for args in &[&["version"][..], &["--version"][..]] {
let output = Command::new(command).args(*args).output().ok()?;
if output.status.success() {
return Some(runtime_from_version_output(&output.stdout, &output.stderr));
}
}
None
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Config {
pub token: Option<String>,
pub backend_url: Option<String>,
pub container_cli: Option<String>,
pub managed_buildkit: Option<bool>,
}
impl Config {
pub fn config_path() -> Result<PathBuf> {
let home = dirs::home_dir().context("Failed to get home directory")?;
let config_dir = home.join(".config").join("rise");
if !config_dir.exists() {
fs::create_dir_all(&config_dir).context("Failed to create config directory")?;
}
Ok(config_dir.join("config.json"))
}
pub fn load() -> Result<Self> {
let config_path = Self::config_path()?;
if !config_path.exists() {
return Ok(Config::default());
}
let contents = fs::read_to_string(&config_path).context("Failed to read config file")?;
let config: Config =
serde_json::from_str(&contents).context("Failed to parse config file")?;
Ok(config)
}
pub fn save(&self) -> Result<()> {
let config_path = Self::config_path()?;
let json = serde_json::to_string_pretty(self).context("Failed to serialize config")?;
fs::write(&config_path, json).context("Failed to write config file")?;
Ok(())
}
pub fn set_token(&mut self, token: String) -> Result<()> {
self.token = Some(token);
self.save()
}
pub fn get_token(&self) -> Option<String> {
#[cfg(not(test))]
if let Ok(token) = std::env::var("RISE_TOKEN") {
crate::login::token_utils::log_token_debug(&token, "RISE_TOKEN environment variable");
return Some(token);
}
if let Some(token) = self.token.as_deref() {
crate::login::token_utils::log_token_debug(token, "~/.config/rise/config.json");
}
self.token.clone()
}
pub fn set_backend_url(&mut self, url: String) -> Result<()> {
self.backend_url = Some(normalize_backend_url(&url));
self.save()
}
pub fn get_backend_url(&self) -> String {
#[cfg(not(test))]
if let Ok(url) = std::env::var("RISE_URL") {
return normalize_backend_url(&url);
}
self.backend_url
.as_deref()
.map(normalize_backend_url)
.unwrap_or_else(|| "http://localhost:3000".to_string())
}
#[allow(dead_code)]
pub fn set_container_cli(&mut self, cli: String) -> Result<()> {
self.container_cli = Some(cli);
self.save()
}
pub fn get_container_cli(&self) -> ContainerCli {
#[cfg(not(test))]
if let Ok(cli) = std::env::var("RISE_CONTAINER_CLI") {
return ContainerCli::from_command(cli);
}
if let Some(ref cli) = self.container_cli {
return ContainerCli::from_command(cli.clone());
}
detect_container_cli()
}
#[allow(dead_code)]
pub fn get_managed_buildkit(&self) -> bool {
#[cfg(not(test))]
if let Some(val) = crate::build::parse_bool_env_var("RISE_MANAGED_BUILDKIT") {
return val;
}
self.managed_buildkit.unwrap_or(false)
}
#[allow(dead_code)]
pub fn set_managed_buildkit(&mut self, enabled: bool) -> Result<()> {
self.managed_buildkit = Some(enabled);
self.save()
}
}
fn detect_container_cli() -> ContainerCli {
if let Some(runtime) = probe_runtime("docker") {
return ContainerCli {
command: "docker".to_string(),
runtime,
buildx_supports_push: detect_buildx_push_support("docker"),
};
}
if probe_runtime("podman").is_some() {
return ContainerCli {
command: "podman".to_string(),
runtime: ContainerRuntime::Podman,
buildx_supports_push: detect_buildx_push_support("podman"),
};
}
ContainerCli {
command: "docker".to_string(),
runtime: ContainerRuntime::Docker,
buildx_supports_push: detect_buildx_push_support("docker"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn config(overrides: impl FnOnce(&mut Config)) -> Config {
let mut c = Config::default();
overrides(&mut c);
c
}
#[test]
fn test_backend_url_default() {
assert_eq!(Config::default().get_backend_url(), "http://localhost:3000");
}
#[test]
fn test_backend_url_from_config() {
let c = config(|c| c.backend_url = Some("https://api.example.com".to_string()));
assert_eq!(c.get_backend_url(), "https://api.example.com");
}
#[test]
fn test_backend_url_trailing_slash_is_trimmed() {
let c = config(|c| c.backend_url = Some("https://api.example.com/".to_string()));
assert_eq!(c.get_backend_url(), "https://api.example.com");
}
#[test]
fn test_normalize_backend_url_trims_multiple_trailing_slashes() {
assert_eq!(
normalize_backend_url("https://api.example.com///"),
"https://api.example.com"
);
}
#[test]
fn test_token_none_by_default() {
assert_eq!(Config::default().get_token(), None);
}
#[test]
fn test_token_from_config() {
let c = config(|c| c.token = Some("config-token".to_string()));
assert_eq!(c.get_token(), Some("config-token".to_string()));
}
#[test]
fn test_managed_buildkit_default_false() {
assert!(!Config::default().get_managed_buildkit());
}
#[test]
fn test_managed_buildkit_from_config() {
let c = config(|c| c.managed_buildkit = Some(true));
assert!(c.get_managed_buildkit());
let c = config(|c| c.managed_buildkit = Some(false));
assert!(!c.get_managed_buildkit());
}
#[test]
fn test_runtime_from_version_output_docker_sample() {
let runtime = runtime_from_version_output(b"Docker version 27.3.1, build ce12230\n", b"");
assert_eq!(runtime, ContainerRuntime::Docker);
}
#[test]
fn test_runtime_from_version_output_podman_sample_stdout() {
let runtime = runtime_from_version_output(b"podman version 5.0.2\n", b"");
assert_eq!(runtime, ContainerRuntime::Podman);
}
#[test]
fn test_runtime_from_version_output_podman_sample_stderr() {
let runtime = runtime_from_version_output(
b"Docker version 5.0.2\n",
b"Emulate Docker CLI using podman. Create /etc/containers/nodocker to quiet msg.\n",
);
assert_eq!(runtime, ContainerRuntime::Podman);
}
#[test]
fn test_runtime_from_version_output_docker_cli_podman_server() {
let stdout = b"Client:\n Version: 29.2.1\n\nServer: linux/arm64/fedora-43\n Podman Engine:\n Version: 5.7.1\n";
let runtime = runtime_from_version_output(stdout, b"");
assert_eq!(runtime, ContainerRuntime::Podman);
}
#[test]
fn test_command_file_name_extracts_binary_name() {
assert_eq!(command_file_name("podman"), Some("podman"));
assert_eq!(command_file_name("/usr/bin/podman"), Some("podman"));
assert_eq!(command_file_name("/usr/local/bin/docker"), Some("docker"));
}
}