use anyhow::{bail, Context, Result};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
pub struct FloxIntegration;
impl FloxIntegration {
pub fn is_available() -> bool {
Command::new("flox")
.arg("--version")
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
pub fn version() -> Result<String> {
let output = Command::new("flox")
.arg("--version")
.output()
.context("Failed to execute flox --version")?;
if !output.status.success() {
bail!("Flox version check failed");
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ServiceStatus {
pub name: String,
pub status: String,
pub pid: Option<u32>,
}
pub struct FloxEnvironment {
pub path: PathBuf,
}
impl FloxEnvironment {
pub fn new<P: AsRef<Path>>(path: P) -> Self {
Self {
path: path.as_ref().to_path_buf(),
}
}
pub fn exists(&self) -> bool {
self.path.join(".flox").exists()
}
pub fn manifest_path(&self) -> PathBuf {
self.path.join(".flox").join("env").join("manifest.toml")
}
pub fn has_manifest(&self) -> bool {
self.manifest_path().exists()
}
pub fn get_activation_env(&self) -> Result<HashMap<String, String>> {
let output = Command::new("flox")
.arg("activate")
.arg("--print-script")
.arg("-d")
.arg(&self.path)
.output()
.context("Failed to execute flox activate --print-script")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to get Flox activation script: {}", stderr);
}
let script = String::from_utf8_lossy(&output.stdout);
Self::parse_activation_script(&script)
}
fn parse_activation_script(script: &str) -> Result<HashMap<String, String>> {
let mut env_vars = HashMap::new();
for line in script.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix("export ") {
if let Some((name, value)) = rest.split_once('=') {
let name = name.trim();
let value = Self::unquote_value(value.trim());
env_vars.insert(name.to_string(), value);
}
}
}
Ok(env_vars)
}
fn unquote_value(value: &str) -> String {
let value = value.trim();
if (value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\''))
{
value[1..value.len() - 1].to_string()
} else {
value.to_string()
}
}
pub fn run_in_env(&self, command: &str, args: &[&str]) -> Result<Output> {
let mut cmd = Command::new("flox");
cmd.arg("activate")
.arg("-d")
.arg(&self.path)
.arg("--")
.arg(command);
for arg in args {
cmd.arg(arg);
}
cmd.output()
.context(format!("Failed to run '{}' in Flox environment", command))
}
pub fn services_start(&self, services: &[&str]) -> Result<()> {
let mut cmd = Command::new("flox");
cmd.arg("services").arg("start").arg("-d").arg(&self.path);
for service in services {
cmd.arg(service);
}
let output = cmd.output().context("Failed to start Flox services")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to start Flox services: {}", stderr);
}
Ok(())
}
pub fn services_stop(&self, services: &[&str]) -> Result<()> {
let mut cmd = Command::new("flox");
cmd.arg("services").arg("stop").arg("-d").arg(&self.path);
for service in services {
cmd.arg(service);
}
let output = cmd.output().context("Failed to stop Flox services")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to stop Flox services: {}", stderr);
}
Ok(())
}
pub fn services_status(&self) -> Result<Vec<ServiceStatus>> {
let output = Command::new("flox")
.arg("services")
.arg("status")
.arg("-d")
.arg(&self.path)
.output()
.context("Failed to get Flox services status")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("no services")
|| stderr.contains("No services")
|| stderr.contains("does not have any services")
{
return Ok(Vec::new());
}
bail!("Failed to get Flox services status: {}", stderr);
}
let stdout = String::from_utf8_lossy(&output.stdout);
Self::parse_services_status(&stdout)
}
fn parse_services_status(output: &str) -> Result<Vec<ServiceStatus>> {
let mut services = Vec::new();
let mut lines = output.lines();
if lines.next().is_none() {
return Ok(services);
}
for line in lines {
let line = line.trim();
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
let name = parts[0].to_string();
let status = parts[1].to_string();
let pid = parts.get(2).and_then(|p| p.parse::<u32>().ok());
services.push(ServiceStatus { name, status, pid });
}
}
Ok(services)
}
pub fn services_logs(&self, service: &str, follow: bool, tail: Option<u32>) -> Result<String> {
let mut cmd = Command::new("flox");
cmd.arg("services").arg("logs").arg("-d").arg(&self.path);
if follow {
cmd.arg("--follow");
}
if let Some(n) = tail {
cmd.arg("--tail").arg(n.to_string());
}
cmd.arg(service);
let output = cmd.output().context("Failed to get Flox service logs")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to get logs for service '{}': {}", service, stderr);
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
pub fn services_restart(&self, services: &[&str]) -> Result<()> {
let mut cmd = Command::new("flox");
cmd.arg("services").arg("restart").arg("-d").arg(&self.path);
for service in services {
cmd.arg(service);
}
let output = cmd.output().context("Failed to restart Flox services")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to restart Flox services: {}", stderr);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_available_returns_bool() {
let _ = FloxIntegration::is_available();
}
#[test]
fn test_version_when_flox_not_installed() {
let result = FloxIntegration::version();
if FloxIntegration::is_available() {
assert!(result.is_ok());
let version = result.unwrap();
assert!(!version.is_empty());
} else {
assert!(result.is_err());
}
}
#[test]
fn test_flox_environment_new() {
let env = FloxEnvironment::new("/some/path");
assert_eq!(env.path, PathBuf::from("/some/path"));
}
#[test]
fn test_manifest_path() {
let env = FloxEnvironment::new("/project");
assert_eq!(
env.manifest_path(),
PathBuf::from("/project/.flox/env/manifest.toml")
);
}
#[test]
fn test_parse_activation_script_export_double_quotes() {
let script = r#"
export PATH="/nix/store/abc123/bin:$PATH"
export FLOX_ENV="/path/to/env"
"#;
let result = FloxEnvironment::parse_activation_script(script).unwrap();
assert_eq!(
result.get("PATH"),
Some(&"/nix/store/abc123/bin:$PATH".to_string())
);
assert_eq!(result.get("FLOX_ENV"), Some(&"/path/to/env".to_string()));
}
#[test]
fn test_parse_activation_script_export_single_quotes() {
let script = r#"
export MY_VAR='single quoted value'
"#;
let result = FloxEnvironment::parse_activation_script(script).unwrap();
assert_eq!(
result.get("MY_VAR"),
Some(&"single quoted value".to_string())
);
}
#[test]
fn test_parse_activation_script_export_no_quotes() {
let script = r#"
export SIMPLE=value
"#;
let result = FloxEnvironment::parse_activation_script(script).unwrap();
assert_eq!(result.get("SIMPLE"), Some(&"value".to_string()));
}
#[test]
fn test_parse_activation_script_empty() {
let script = "";
let result = FloxEnvironment::parse_activation_script(script).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_parse_activation_script_ignores_non_export() {
let script = r#"
# This is a comment
echo "Hello"
export VALID="value"
some_function() { echo "hi"; }
"#;
let result = FloxEnvironment::parse_activation_script(script).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result.get("VALID"), Some(&"value".to_string()));
}
#[test]
fn test_unquote_value_double_quotes() {
assert_eq!(
FloxEnvironment::unquote_value("\"hello world\""),
"hello world"
);
}
#[test]
fn test_unquote_value_single_quotes() {
assert_eq!(
FloxEnvironment::unquote_value("'hello world'"),
"hello world"
);
}
#[test]
fn test_unquote_value_no_quotes() {
assert_eq!(FloxEnvironment::unquote_value("hello"), "hello");
}
#[test]
fn test_unquote_value_mismatched_quotes() {
assert_eq!(FloxEnvironment::unquote_value("\"hello'"), "\"hello'");
}
#[test]
fn test_parse_services_status_multiple_services() {
let output = r#"NAME STATUS PID
postgres Running 12345
redis Running 12346
nginx Stopped
"#;
let result = FloxEnvironment::parse_services_status(output).unwrap();
assert_eq!(result.len(), 3);
assert_eq!(result[0].name, "postgres");
assert_eq!(result[0].status, "Running");
assert_eq!(result[0].pid, Some(12345));
assert_eq!(result[1].name, "redis");
assert_eq!(result[1].status, "Running");
assert_eq!(result[1].pid, Some(12346));
assert_eq!(result[2].name, "nginx");
assert_eq!(result[2].status, "Stopped");
assert_eq!(result[2].pid, None);
}
#[test]
fn test_parse_services_status_empty() {
let output = "";
let result = FloxEnvironment::parse_services_status(output).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_parse_services_status_header_only() {
let output = "NAME STATUS PID\n";
let result = FloxEnvironment::parse_services_status(output).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_service_status_struct() {
let status = ServiceStatus {
name: "postgres".to_string(),
status: "Running".to_string(),
pid: Some(12345),
};
assert_eq!(status.name, "postgres");
assert_eq!(status.status, "Running");
assert_eq!(status.pid, Some(12345));
}
}