use anyhow::{anyhow, Context, Result};
use clap::Subcommand;
use colored::Colorize;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use crate::commands::hook::HookShell;
use crate::commands::profiles::ProfileAction;
use crate::commands::unfree::UnfreeAction;
use crate::commands::{activate, add, init, list, profiles, remove, unfree, update};
use crate::nix::run_nix_command;
#[derive(Subcommand)]
pub enum GlobalAction {
Add {
package: String,
#[arg(short, long)]
version: Option<String>,
#[arg(short = 'p', long)]
profile: Option<String>,
},
Remove {
package: String,
#[arg(short = 'p', long)]
profile: Option<String>,
},
List {
#[arg(short = 'p', long)]
profile: Option<String>,
},
Activate {
#[arg(short = 'p', long)]
profile: Option<String>,
},
Update {
packages: Vec<String>,
#[arg(short, long)]
show: bool,
},
Path,
Ref {
#[arg(short = 'p', long)]
profile: Option<String>,
},
Shellenv {
shell: HookShell,
#[arg(short = 'p', long)]
profile: Option<String>,
},
Profile {
#[command(subcommand)]
action: ProfileAction,
},
Unfree {
#[command(subcommand)]
action: UnfreeAction,
},
}
pub fn global_dir() -> Result<PathBuf> {
global_dir_from(
env::var("FLK_GLOBAL_DIR").ok().as_deref(),
env::var("XDG_CONFIG_HOME").ok().as_deref(),
)
}
fn global_dir_from(flk_global_dir: Option<&str>, xdg_config_home: Option<&str>) -> Result<PathBuf> {
if let Some(dir) = flk_global_dir {
if !dir.trim().is_empty() {
return Ok(PathBuf::from(dir));
}
}
if let Some(xdg) = xdg_config_home {
if !xdg.trim().is_empty() {
return Ok(PathBuf::from(xdg).join("flk").join("global"));
}
}
let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not detect home directory"))?;
Ok(home.join(".config").join("flk").join("global"))
}
fn ensure_global_env() -> Result<PathBuf> {
let dir = global_dir()?;
fs::create_dir_all(&dir).with_context(|| {
format!(
"Failed to create global environment directory '{}'",
dir.display()
)
})?;
env::set_current_dir(&dir).with_context(|| {
format!(
"Failed to enter global environment directory '{}'",
dir.display()
)
})?;
env::remove_var("FLK_FLAKE_REF");
println!(
"{} Using global environment: {}",
"ℹ".blue(),
dir.display().to_string().cyan()
);
if !Path::new("flake.nix").exists() {
init::run(Some("generic".to_string()), false, false)?;
println!(
"\n{} Enter your global tools shell anytime with: {}",
"ℹ".blue(),
"flk global activate".cyan()
);
}
Ok(dir)
}
pub fn run(action: GlobalAction) -> Result<()> {
if let GlobalAction::Path = action {
println!("{}", global_dir()?.display());
return Ok(());
}
if let GlobalAction::Ref { profile } = action {
let dir = global_dir()?;
if !dir.join("flake.nix").exists() {
anyhow::bail!(
"Global environment not initialized. Run any 'flk global' command (e.g. 'flk global list') first."
);
}
env::set_current_dir(&dir).with_context(|| {
format!(
"Failed to enter global environment directory '{}'",
dir.display()
)
})?;
env::remove_var("FLK_FLAKE_REF");
let profile = flk::flake::parsers::utils::resolve_profile(profile)?;
println!("{}#{}", dir.display(), profile);
return Ok(());
}
if let GlobalAction::Shellenv { shell, profile } = action {
return run_shellenv(shell, profile);
}
let user_dir = env::current_dir().context("Failed to read current directory")?;
ensure_global_env()?;
match action {
GlobalAction::Add {
package,
version,
profile,
} => add::run_add(&package, version, profile),
GlobalAction::Remove { package, profile } => remove::run_remove(&package, profile),
GlobalAction::List { profile } => list::run_list(profile),
GlobalAction::Activate { profile } => activate::run_activate_in(profile, Some(&user_dir)),
GlobalAction::Update { packages, show } => update::run_update(packages, show),
GlobalAction::Profile { action } => match action {
ProfileAction::Add {
name,
template,
force,
} => profiles::run_add(name, template, force),
ProfileAction::Remove { name } => profiles::run_remove(name),
ProfileAction::List => profiles::run_list(),
ProfileAction::SetDefault { profile } => profiles::run_set_default(profile),
},
GlobalAction::Unfree { action } => unfree::run(action),
GlobalAction::Path | GlobalAction::Ref { .. } | GlobalAction::Shellenv { .. } => {
unreachable!("handled above")
}
}
}
fn run_shellenv(shell: HookShell, profile: Option<String>) -> Result<()> {
let dir = global_dir()?;
if !dir.join("flake.nix").exists() {
println!("# flk global shellenv: global environment not initialized; run any 'flk global' command first");
return Ok(());
}
env::set_current_dir(&dir).with_context(|| {
format!(
"Failed to enter global environment directory '{}'",
dir.display()
)
})?;
env::remove_var("FLK_FLAKE_REF");
let profile = flk::flake::parsers::utils::resolve_profile(profile)?;
let profile_link = dir.join(".flk").join(format!(".nix-profile-{profile}"));
if !profile_link.exists() {
println!(
"# flk global shellenv: environment not built yet; run 'flk global activate' once"
);
return Ok(());
}
let link = profile_link.display().to_string();
let (stdout, stderr, success) = run_nix_command(&["print-dev-env", &link, "--json"])?;
if !success {
anyhow::bail!("nix print-dev-env failed for '{}': {}", link, stderr);
}
let env_json: serde_json::Value =
serde_json::from_str(&stdout).context("Failed to parse nix print-dev-env output")?;
let path_value = env_json["variables"]["PATH"]["value"]
.as_str()
.context("nix print-dev-env output has no PATH variable")?;
let owners: Vec<String> = env_json["variables"]["pkgsHostTarget"]["value"]
.as_array()
.map(|values| {
values
.iter()
.filter_map(|value| value.as_str().map(ToOwned::to_owned))
.collect()
})
.unwrap_or_default();
if owners.is_empty() {
println!("# flk global shellenv: cannot determine the environment's packages (nix print-dev-env has no pkgsHostTarget); nothing exported");
return Ok(());
}
let entries = exported_path_entries(path_value, &owners);
if entries.is_empty() {
println!("# flk global shellenv: global environment provides no store paths");
return Ok(());
}
match shell {
HookShell::Bash | HookShell::Zsh => {
let joined = entries.join(":");
println!(
"case \":$PATH:\" in\n *\":{joined}:\"*) ;;\n *) export PATH=\"$PATH:{joined}\" ;;\nesac"
);
}
HookShell::Fish => {
let joined = entries.join(":");
println!(
"if not string match -q '*{joined}*' (string join : $PATH)\n set -gx PATH $PATH {}\nend",
entries.join(" ")
);
}
}
Ok(())
}
fn exported_path_entries<'a>(path_value: &'a str, owners: &[String]) -> Vec<&'a str> {
let owned_by_env = |entry: &str| {
owners
.iter()
.any(|owner| entry == owner || entry.starts_with(&format!("{owner}/")))
};
let mut seen = std::collections::HashSet::new();
path_value
.split(':')
.filter(|entry| owned_by_env(entry) && seen.insert(*entry))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn global_dir_prefers_flk_global_dir() {
assert_eq!(
global_dir_from(Some("/custom/global"), Some("/xdg")).unwrap(),
PathBuf::from("/custom/global")
);
}
#[test]
fn global_dir_falls_back_to_xdg_config_home() {
assert_eq!(
global_dir_from(None, Some("/xdg")).unwrap(),
PathBuf::from("/xdg/flk/global")
);
}
fn owners(paths: &[&str]) -> Vec<String> {
paths.iter().map(ToString::to_string).collect()
}
#[test]
fn exported_path_entries_drops_the_stdenv_toolchain() {
let path = concat!(
"/nix/store/aaa-gcc-wrapper-15.2.0/bin:",
"/nix/store/bbb-ripgrep-14.1.0/bin:",
"/nix/store/ccc-patchelf-0.15.2/bin"
);
assert_eq!(
exported_path_entries(path, &owners(&["/nix/store/bbb-ripgrep-14.1.0"])),
vec!["/nix/store/bbb-ripgrep-14.1.0/bin"]
);
}
#[test]
fn exported_path_entries_matches_owners_and_their_subdirectories() {
let path = concat!(
"/nix/store/aaa-hello-1.0:",
"/nix/store/aaa-hello-1.0/bin:",
"/nix/store/aaa-hello-1.0-suffix/bin"
);
assert_eq!(
exported_path_entries(path, &owners(&["/nix/store/aaa-hello-1.0"])),
vec!["/nix/store/aaa-hello-1.0", "/nix/store/aaa-hello-1.0/bin"]
);
}
#[test]
fn exported_path_entries_dedupes_and_preserves_order() {
let path = concat!(
"/nix/store/bbb-fd-10.2.0/bin:",
"/usr/bin:",
"/nix/store/aaa-hello-1.0/bin:",
"/nix/store/bbb-fd-10.2.0/bin"
);
assert_eq!(
exported_path_entries(
path,
&owners(&["/nix/store/aaa-hello-1.0", "/nix/store/bbb-fd-10.2.0"])
),
vec![
"/nix/store/bbb-fd-10.2.0/bin",
"/nix/store/aaa-hello-1.0/bin"
]
);
}
#[test]
fn exported_path_entries_without_owners_exports_nothing() {
let path = "/nix/store/aaa-gcc-wrapper-15.2.0/bin:/usr/bin";
assert!(exported_path_entries(path, &[]).is_empty());
}
#[test]
fn global_dir_ignores_empty_overrides() {
let home = dirs::home_dir().expect("test environment must have a home dir");
assert_eq!(
global_dir_from(Some(" "), Some("")).unwrap(),
home.join(".config").join("flk").join("global")
);
}
}