use anyhow::{bail, Context, Result};
use clap::Subcommand;
use colored::Colorize;
use regex::{Captures, Regex};
use std::fs;
use std::path::{Path, PathBuf};
use flk::flake::parsers::config as flk_config;
use flk::flake::parsers::config::AllowUnfree;
use flk::flake::parsers::utils;
const LEGACY_PATH: &str = ".flk/default.nix";
#[derive(Subcommand)]
pub enum UnfreeAction {
Add {
package: String,
#[arg(short = 'p', long)]
profile: Option<String>,
#[arg(long, conflicts_with = "profile")]
all: bool,
},
Remove {
package: String,
#[arg(short = 'p', long)]
profile: Option<String>,
#[arg(long, conflicts_with = "profile")]
all: bool,
},
List {
#[arg(short = 'p', long)]
profile: Option<String>,
#[arg(long, conflicts_with = "profile")]
all: bool,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Target {
Environment,
Profile(String),
}
impl Target {
fn label(&self) -> String {
match self {
Target::Environment => "every profile".to_string(),
Target::Profile(name) => format!("profile '{}'", name),
}
}
}
pub fn run(action: UnfreeAction) -> Result<()> {
match action {
UnfreeAction::Add {
package,
profile,
all,
} => run_add(&package, profile, all),
UnfreeAction::Remove {
package,
profile,
all,
} => run_remove(&package, profile, all),
UnfreeAction::List { profile, all } => run_list(profile, all),
}
}
fn resolve_target(profile: Option<String>, all: bool) -> Result<Target> {
if all {
return Ok(Target::Environment);
}
let profile = utils::resolve_profile(profile)?;
let path = profile_path(&profile);
if !path.exists() {
bail!(
"Profile {} does not exist ({} not found).\n\
Target another profile with {}, or apply to every profile with {}.",
profile.cyan(),
path.display(),
"-p <profile>".yellow(),
"--all".yellow()
);
}
Ok(Target::Profile(profile))
}
fn profile_path(profile: &str) -> PathBuf {
Path::new(".flk/profiles").join(format!("{}.nix", profile))
}
pub fn run_add(package: &str, profile: Option<String>, all: bool) -> Result<()> {
if !is_valid_package_name(package) {
bail!(
"Invalid package name '{}'. Expected a nixpkgs package name such as {}.",
package.cyan(),
"vscode".green()
);
}
let target = resolve_target(profile, all)?;
let mut packages = read_list(&target)?;
if packages.iter().any(|p| p == package) {
println!(
"{} '{}' is already allowed for {}.",
"ℹ".blue(),
package.cyan(),
target.label()
);
return Ok(());
}
packages.push(package.to_string());
packages.sort();
write_list(&target, &packages)?;
println!(
"{} Allowed unfree package '{}' for {}.",
"✓".green().bold(),
package.cyan(),
target.label()
);
println!(
"{} Install it with {} and reload with {}.",
"ℹ".blue(),
match &target {
Target::Profile(name) => format!("flk add {} -p {}", package, name),
Target::Environment => format!("flk add {}", package),
}
.yellow(),
"refresh".yellow()
);
Ok(())
}
pub fn run_remove(package: &str, profile: Option<String>, all: bool) -> Result<()> {
let target = resolve_target(profile, all)?;
let mut packages = read_list(&target)?;
let before = packages.len();
packages.retain(|p| p != package);
if packages.len() == before {
bail!(
"'{}' is not in the unfree allow-list for {}. Run {} to see what is.",
package.cyan(),
target.label(),
"flk unfree list".yellow()
);
}
write_list(&target, &packages)?;
println!(
"{} Disallowed unfree package '{}' for {}.",
"✓".green().bold(),
package.cyan(),
target.label()
);
Ok(())
}
pub fn run_list(profile: Option<String>, all: bool) -> Result<()> {
print_list(
&Target::Environment,
&read_allow_unfree(&Target::Environment)?,
);
if all {
return Ok(());
}
let target = resolve_target(profile, false)?;
print_list(&target, &read_allow_unfree(&target)?);
Ok(())
}
fn print_list(target: &Target, allow_unfree: &AllowUnfree) {
let scope = match target {
Target::Environment => "Environment-wide (every profile)".to_string(),
Target::Profile(name) => format!("Profile '{}'", name),
};
match allow_unfree {
AllowUnfree::All => {
println!(
"{} {}: {} unfree packages allowed ({} is set).",
"⚠".yellow().bold(),
scope,
"all".yellow().bold(),
"allowUnfree = true".yellow()
);
println!(
" Replace it with a list to keep the exception reviewable: {}",
"allowUnfree = [\"vscode\"];".green()
);
}
AllowUnfree::List(packages) if packages.is_empty() => {
println!("{} {}: nothing allowed.", "ℹ".blue(), scope);
}
AllowUnfree::List(packages) => {
println!("{} {}:", "ℹ".blue(), scope);
for package in packages {
println!("- {}", package.cyan());
}
}
}
}
fn read_list(target: &Target) -> Result<Vec<String>> {
match read_allow_unfree(target)? {
AllowUnfree::List(packages) => Ok(packages),
AllowUnfree::All => bail!(
"{} sets {}, which already allows every unfree package.\n\
flk manages the list form only — replace it with {} to use {}.",
target.label(),
"allowUnfree = true".yellow(),
"allowUnfree = [];".green(),
"flk unfree".yellow()
),
}
}
fn read_allow_unfree(target: &Target) -> Result<AllowUnfree> {
Ok(flk_config::parse_allow_unfree(&read_target(target)?))
}
fn write_list(target: &Target, packages: &[String]) -> Result<()> {
match target {
Target::Profile(name) => write_profile(name, packages),
Target::Environment if flk_config::exists() => {
flk_config::write_allow_unfree(packages).context("Failed to update .flk/config.nix")
}
Target::Environment => write_legacy(packages),
}
}
fn read_target(target: &Target) -> Result<String> {
let path = match target {
Target::Profile(name) => profile_path(name),
Target::Environment if flk_config::exists() => flk_config::config_path().to_path_buf(),
Target::Environment => {
return fs::read_to_string(LEGACY_PATH).context(
"Failed to read .flk/config.nix or .flk/default.nix. Have you run 'flk init'?",
)
}
};
fs::read_to_string(&path).with_context(|| format!("Failed to read {}", path.display()))
}
fn write_profile(profile: &str, packages: &[String]) -> Result<()> {
let path = profile_path(profile);
let content =
fs::read_to_string(&path).with_context(|| format!("Failed to read {}", path.display()))?;
let content = flk_config::set_allow_unfree_in_profile(&content, packages)
.with_context(|| format!("Failed to update {}", path.display()))?;
fs::write(&path, content).with_context(|| format!("Failed to write {}", path.display()))?;
Ok(())
}
fn write_legacy(packages: &[String]) -> Result<()> {
let path = Path::new(LEGACY_PATH);
let content =
fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?;
let content = ensure_legacy_scaffold(&content)?;
let content = flk_config::set_allow_unfree_in_text(&content, packages)?;
fs::write(path, content).with_context(|| format!("Failed to write {}", path.display()))?;
Ok(())
}
fn ensure_legacy_scaffold(content: &str) -> Result<String> {
let mut out = content.to_string();
if !out.contains("nixpkgsConfig") {
let anchor = Regex::new(r"(?m)^(\s*)lib\s*=\s*nixpkgs\.lib;\s*$").unwrap();
if !anchor.is_match(&out) {
bail!(
"Could not find the '{}' binding in {}.\n\
This driver has been customized; add the unfree plumbing by hand, \
or run {} to move to the slim layout.",
"lib = nixpkgs.lib;",
LEGACY_PATH,
"flk migrate".yellow()
);
}
out = anchor
.replace(&out, |caps: &Captures| {
let indent = &caps[1];
format!(
"{indent}lib = nixpkgs.lib;\n\
\n\
{indent}# Unfree packages this environment is allowed to evaluate, by package\n\
{indent}# name (`lib.getName`). Managed by `flk unfree`.\n\
{indent}allowUnfree = [];\n\
{indent}nixpkgsConfig =\n\
{indent} if builtins.isBool allowUnfree\n\
{indent} then {{inherit allowUnfree;}}\n\
{indent} else if allowUnfree == []\n\
{indent} then {{}}\n\
{indent} else {{\n\
{indent} allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) allowUnfree;\n\
{indent} }};",
indent = indent
)
})
.to_string();
}
if !out.contains("config = nixpkgsConfig;") {
let import_re =
Regex::new(r"(?s)(import\s+nixpkgs\s*\{\s*\n(\s*)inherit system overlays;\n)(\s*\};)")
.unwrap();
if !import_re.is_match(&out) {
bail!(
"Could not find the nixpkgs import in {}.\n\
This driver has been customized; add {} to it by hand, \
or run {} to move to the slim layout.",
LEGACY_PATH,
"config = nixpkgsConfig;".green(),
"flk migrate".yellow()
);
}
out = import_re
.replace(&out, |caps: &Captures| {
format!(
"{}{}config = nixpkgsConfig;\n{}",
&caps[1], &caps[2], &caps[3]
)
})
.to_string();
}
Ok(out)
}
fn is_valid_package_name(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '+'))
}
#[cfg(test)]
mod tests {
use super::*;
const LEGACY_TEMPLATE: &str = include_str!("../../templates/default.nix");
#[test]
fn rejects_names_that_would_break_the_nix_file() {
assert!(is_valid_package_name("vscode"));
assert!(is_valid_package_name("google-chrome"));
assert!(is_valid_package_name("nodejs_20"));
assert!(!is_valid_package_name(""));
assert!(!is_valid_package_name("vs\"code"));
assert!(!is_valid_package_name("a b"));
assert!(!is_valid_package_name("${pkgs.hello}"));
}
#[test]
fn current_legacy_template_needs_no_scaffolding() {
let out = ensure_legacy_scaffold(LEGACY_TEMPLATE).unwrap();
assert_eq!(out, LEGACY_TEMPLATE);
}
const OLD_LEGACY: &str = r#"inputs: let
inherit (inputs) flake-utils nixpkgs profile-lib;
lib = nixpkgs.lib;
in
flake-utils.lib.eachDefaultSystem (
system: let
overlays = import ./overlays.nix system;
pkgs = import nixpkgs {
inherit system overlays;
};
in
{}
)
"#;
#[test]
fn scaffolds_a_pre_unfree_legacy_driver() {
let out = ensure_legacy_scaffold(OLD_LEGACY).unwrap();
assert!(out.contains("allowUnfree = [];"));
assert!(out.contains("allowUnfreePredicate"));
assert!(out.contains("config = nixpkgsConfig;"));
let binding = out.find("nixpkgsConfig =").unwrap();
let in_kw = out.find("\nin\n").unwrap();
assert!(binding < in_kw);
}
#[test]
fn scaffolding_is_idempotent() {
let once = ensure_legacy_scaffold(OLD_LEGACY).unwrap();
let twice = ensure_legacy_scaffold(&once).unwrap();
assert_eq!(once, twice);
}
#[test]
fn scaffolded_driver_accepts_an_allow_list() {
let out = ensure_legacy_scaffold(OLD_LEGACY).unwrap();
let out = flk_config::set_allow_unfree_in_text(&out, &["vscode".to_string()]).unwrap();
assert_eq!(
flk_config::parse_allow_unfree(&out),
AllowUnfree::List(vec!["vscode".to_string()])
);
assert_eq!(out.matches("allowUnfree = [").count(), 1);
}
#[test]
fn refuses_a_customized_driver_rather_than_writing_inert_config() {
let customized = "inputs: let\n pkgs = somethingElse;\nin {}\n";
let err = ensure_legacy_scaffold(customized).unwrap_err().to_string();
assert!(err.contains("lib = nixpkgs.lib;"));
}
}