use std::fs;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ResolvedNixpkgPackage {
pub name: String,
#[serde(default)]
pub version_spec: Option<String>,
pub resolved_version: String,
pub attribute_path: String,
pub commit_hash: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub platforms: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CustomPackage {
pub name: String,
pub input_name: String,
pub input_url: String,
pub package_output: String, #[serde(default)]
pub source_name: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")]
pub rev: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub platforms: Option<Vec<String>>,
}
impl CustomPackage {
pub fn source_package_name(&self) -> &str {
self.source_name.as_deref().unwrap_or(&self.name)
}
pub fn locked_input_url(&self) -> String {
match self.rev.as_deref() {
Some(rev) if !rev.is_empty() => {
let base = strip_shorthand_path_ref(&self.input_url);
let sep = if base.contains('?') { '&' } else { '?' };
format!("{}{}rev={}", base, sep, rev)
}
_ => self.input_url.clone(),
}
}
}
fn strip_shorthand_path_ref(url: &str) -> String {
let Some((scheme, rest)) = url.split_once(':') else {
return url.to_string();
};
if !matches!(scheme, "github" | "gitlab" | "sourcehut") {
return url.to_string();
}
let (path, query) = match rest.split_once('?') {
Some((p, q)) => (p, Some(q)),
None => (rest, None),
};
let segments: Vec<&str> = path.split('/').collect();
let trimmed = if segments.len() >= 3 {
segments[..2].join("/")
} else {
path.to_string()
};
match query {
Some(q) => format!("{scheme}:{trimmed}?{q}"),
None => format!("{scheme}:{trimmed}"),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageState {
pub version: u32,
#[serde(default)]
pub packages: Vec<String>,
#[serde(default)]
pub resolved_packages: Vec<ResolvedNixpkgPackage>,
#[serde(default)]
pub custom_packages: Vec<CustomPackage>,
}
impl Default for PackageState {
fn default() -> Self {
Self {
version: 2,
packages: Vec::new(),
resolved_packages: Vec::new(),
custom_packages: Vec::new(),
}
}
}
impl PackageState {
pub fn load(path: &Path) -> Result<Self> {
if !path.exists() {
return Ok(Self::default());
}
let content = fs::read_to_string(path)?;
let mut state: Self =
serde_json::from_str(&content).map_err(|e| Error::StateFile(e.to_string()))?;
if state.version < 2 {
state.version = 2;
}
Ok(state)
}
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let content =
serde_json::to_string_pretty(self).map_err(|e| Error::StateFile(e.to_string()))?;
let tmp_path = path.with_extension("json.tmp");
fs::write(&tmp_path, &content)?;
fs::rename(&tmp_path, path)?;
Ok(())
}
#[allow(dead_code)]
pub fn add_package(&mut self, name: &str) {
if !self.packages.contains(&name.to_string()) {
self.packages.push(name.to_string());
self.packages.sort();
}
}
pub fn add_resolved_package(&mut self, pkg: ResolvedNixpkgPackage) {
self.packages.retain(|p| p != &pkg.name);
self.resolved_packages.retain(|p| p.name != pkg.name);
self.resolved_packages.push(pkg);
self.resolved_packages.sort_by(|a, b| a.name.cmp(&b.name));
}
#[allow(dead_code)]
pub fn get_resolved_package(&self, name: &str) -> Option<&ResolvedNixpkgPackage> {
self.resolved_packages.iter().find(|p| p.name == name)
}
pub fn add_custom_package(&mut self, pkg: CustomPackage) {
self.custom_packages.retain(|p| p.name != pkg.name);
self.custom_packages.push(pkg);
self.custom_packages.sort_by(|a, b| a.name.cmp(&b.name));
}
pub fn remove_package(&mut self, name: &str) -> bool {
let removed_legacy = self.packages.iter().position(|p| p == name).map(|i| {
self.packages.remove(i);
true
});
let removed_resolved = self
.resolved_packages
.iter()
.position(|p| p.name == name)
.map(|i| {
self.resolved_packages.remove(i);
true
});
let removed_custom = self
.custom_packages
.iter()
.position(|p| p.name == name)
.map(|i| {
self.custom_packages.remove(i);
true
});
removed_legacy.unwrap_or(false)
|| removed_resolved.unwrap_or(false)
|| removed_custom.unwrap_or(false)
}
pub fn has_package(&self, name: &str) -> bool {
self.packages.contains(&name.to_string())
|| self.resolved_packages.iter().any(|p| p.name == name)
|| self.custom_packages.iter().any(|p| p.name == name)
}
#[allow(dead_code)]
pub fn is_legacy_package(&self, name: &str) -> bool {
self.packages.contains(&name.to_string())
}
}
#[cfg(test)]
impl PackageState {
pub fn all_package_names(&self) -> Vec<String> {
let mut names: Vec<String> = self.packages.clone();
names.extend(self.resolved_packages.iter().map(|p| p.name.clone()));
names.extend(self.custom_packages.iter().map(|p| p.name.clone()));
names.sort();
names.dedup();
names
}
}
pub fn get_state_path(profile_dir: &Path) -> std::path::PathBuf {
profile_dir.join("packages.json")
}
pub const VALID_PLATFORMS: &[&str] = &[
"x86_64-darwin",
"aarch64-darwin",
"x86_64-linux",
"aarch64-linux",
];
const PLATFORM_ALIASES: &[(&str, &[&str])] = &[
("darwin", &["x86_64-darwin", "aarch64-darwin"]),
("macos", &["x86_64-darwin", "aarch64-darwin"]),
("linux", &["x86_64-linux", "aarch64-linux"]),
];
pub fn normalize_platforms(platforms: &[String]) -> std::result::Result<Vec<String>, String> {
let mut result = Vec::new();
for p in platforms {
let p_lower = p.to_lowercase();
if let Some((_, expanded)) = PLATFORM_ALIASES.iter().find(|(alias, _)| *alias == p_lower) {
for exp in *expanded {
if !result.contains(&exp.to_string()) {
result.push(exp.to_string());
}
}
} else if VALID_PLATFORMS.contains(&p_lower.as_str()) {
if !result.contains(&p_lower) {
result.push(p_lower);
}
} else {
return Err(format!(
"Invalid platform '{}'. Valid platforms: darwin, macos, linux, {}",
p,
VALID_PLATFORMS.join(", ")
));
}
}
result.sort();
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_default_state() {
let state = PackageState::default();
assert_eq!(state.version, 2);
assert!(state.packages.is_empty());
assert!(state.resolved_packages.is_empty());
assert!(state.custom_packages.is_empty());
}
#[test]
fn test_add_package() {
let mut state = PackageState::default();
state.add_package("ripgrep");
state.add_package("fzf");
assert_eq!(state.packages.len(), 2);
assert!(state.has_package("ripgrep"));
assert!(state.has_package("fzf"));
}
#[test]
fn test_add_package_deduplication() {
let mut state = PackageState::default();
state.add_package("ripgrep");
state.add_package("ripgrep");
assert_eq!(state.packages.len(), 1);
}
#[test]
fn test_add_package_sorted() {
let mut state = PackageState::default();
state.add_package("zsh");
state.add_package("bat");
state.add_package("fzf");
assert_eq!(state.packages, vec!["bat", "fzf", "zsh"]);
}
#[test]
fn test_add_custom_package() {
let mut state = PackageState::default();
let pkg = CustomPackage {
name: "neovim".to_string(),
input_name: "neovim-nightly".to_string(),
input_url: "github:nix-community/neovim-nightly-overlay".to_string(),
package_output: "packages".to_string(),
rev: None,
source_name: None,
platforms: None,
};
state.add_custom_package(pkg.clone());
assert_eq!(state.custom_packages.len(), 1);
assert!(state.has_package("neovim"));
}
#[test]
fn test_add_custom_package_replaces_existing() {
let mut state = PackageState::default();
let pkg1 = CustomPackage {
name: "neovim".to_string(),
input_name: "neovim-old".to_string(),
input_url: "github:old/overlay".to_string(),
package_output: "packages".to_string(),
rev: None,
source_name: None,
platforms: None,
};
state.add_custom_package(pkg1);
let pkg2 = CustomPackage {
name: "neovim".to_string(),
input_name: "neovim-new".to_string(),
input_url: "github:new/overlay".to_string(),
package_output: "packages".to_string(),
rev: None,
source_name: None,
platforms: None,
};
state.add_custom_package(pkg2);
assert_eq!(state.custom_packages.len(), 1);
assert_eq!(state.custom_packages[0].input_name, "neovim-new");
}
fn custom_pkg(input_url: &str, rev: Option<&str>) -> CustomPackage {
CustomPackage {
name: "box".to_string(),
input_name: "github-yusukeshib-box".to_string(),
input_url: input_url.to_string(),
package_output: "packages".to_string(),
rev: rev.map(String::from),
source_name: None,
platforms: None,
}
}
#[test]
fn test_locked_input_url_unpinned() {
let pkg = custom_pkg("github:yusukeshib/box", None);
assert_eq!(pkg.locked_input_url(), "github:yusukeshib/box");
}
#[test]
fn test_locked_input_url_empty_rev_is_unpinned() {
let pkg = custom_pkg("github:yusukeshib/box", Some(""));
assert_eq!(pkg.locked_input_url(), "github:yusukeshib/box");
}
#[test]
fn test_locked_input_url_appends_rev() {
let pkg = custom_pkg("github:yusukeshib/box", Some("abc123"));
assert_eq!(pkg.locked_input_url(), "github:yusukeshib/box?rev=abc123");
}
#[test]
fn test_locked_input_url_appends_rev_with_existing_query() {
let pkg = custom_pkg("github:yusukeshib/box?ref=main", Some("abc123"));
assert_eq!(
pkg.locked_input_url(),
"github:yusukeshib/box?ref=main&rev=abc123"
);
}
#[test]
fn test_locked_input_url_strips_path_ref() {
let pkg = custom_pkg("github:NixOS/nixpkgs/nixpkgs-unstable", Some("deadbeef"));
assert_eq!(pkg.locked_input_url(), "github:NixOS/nixpkgs?rev=deadbeef");
}
#[test]
fn test_locked_input_url_unpinned_keeps_path_ref() {
let pkg = custom_pkg("github:NixOS/nixpkgs/nixpkgs-unstable", None);
assert_eq!(
pkg.locked_input_url(),
"github:NixOS/nixpkgs/nixpkgs-unstable"
);
}
#[test]
fn test_locked_input_url_non_shorthand_unchanged() {
let pkg = custom_pkg("git+https://example.com/repo.git", Some("abc123"));
assert_eq!(
pkg.locked_input_url(),
"git+https://example.com/repo.git?rev=abc123"
);
}
#[test]
fn test_remove_package() {
let mut state = PackageState::default();
state.add_package("ripgrep");
state.add_package("fzf");
assert!(state.remove_package("ripgrep"));
assert!(!state.has_package("ripgrep"));
assert!(state.has_package("fzf"));
}
#[test]
fn test_remove_custom_package() {
let mut state = PackageState::default();
let pkg = CustomPackage {
name: "neovim".to_string(),
input_name: "neovim-nightly".to_string(),
input_url: "github:nix-community/neovim-nightly-overlay".to_string(),
package_output: "packages".to_string(),
rev: None,
source_name: None,
platforms: None,
};
state.add_custom_package(pkg);
assert!(state.remove_package("neovim"));
assert!(!state.has_package("neovim"));
}
#[test]
fn test_remove_nonexistent_package() {
let mut state = PackageState::default();
assert!(!state.remove_package("nonexistent"));
}
#[test]
fn test_all_package_names() {
let mut state = PackageState::default();
state.add_package("ripgrep");
state.add_custom_package(CustomPackage {
name: "neovim".to_string(),
input_name: "neovim-nightly".to_string(),
input_url: "github:nix-community/neovim-nightly-overlay".to_string(),
package_output: "packages".to_string(),
rev: None,
source_name: None,
platforms: None,
});
let names = state.all_package_names();
assert_eq!(names, vec!["neovim", "ripgrep"]);
}
#[test]
fn test_save_and_load() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("packages.json");
let mut state = PackageState::default();
state.add_package("ripgrep");
state.add_custom_package(CustomPackage {
name: "neovim".to_string(),
input_name: "neovim-nightly".to_string(),
input_url: "github:nix-community/neovim-nightly-overlay".to_string(),
package_output: "packages".to_string(),
rev: None,
source_name: None,
platforms: None,
});
state.save(&path).unwrap();
let loaded = PackageState::load(&path).unwrap();
assert_eq!(loaded.packages, state.packages);
assert_eq!(loaded.custom_packages.len(), state.custom_packages.len());
assert_eq!(
loaded.custom_packages[0].name,
state.custom_packages[0].name
);
}
#[test]
fn test_load_nonexistent_returns_default() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("nonexistent.json");
let state = PackageState::load(&path).unwrap();
assert!(state.packages.is_empty());
assert!(state.resolved_packages.is_empty());
assert!(state.custom_packages.is_empty());
}
#[test]
fn test_add_resolved_package() {
let mut state = PackageState::default();
let pkg = ResolvedNixpkgPackage {
name: "nodejs".to_string(),
version_spec: Some("20".to_string()),
resolved_version: "20.11.0".to_string(),
attribute_path: "nodejs_20".to_string(),
commit_hash: "abc123".to_string(),
platforms: None,
};
state.add_resolved_package(pkg.clone());
assert_eq!(state.resolved_packages.len(), 1);
assert!(state.has_package("nodejs"));
assert_eq!(
state
.get_resolved_package("nodejs")
.unwrap()
.resolved_version,
"20.11.0"
);
}
#[test]
fn test_add_resolved_package_removes_legacy() {
let mut state = PackageState::default();
state.add_package("nodejs");
assert!(state.packages.contains(&"nodejs".to_string()));
let pkg = ResolvedNixpkgPackage {
name: "nodejs".to_string(),
version_spec: Some("20".to_string()),
resolved_version: "20.11.0".to_string(),
attribute_path: "nodejs_20".to_string(),
commit_hash: "abc123".to_string(),
platforms: None,
};
state.add_resolved_package(pkg);
assert!(!state.packages.contains(&"nodejs".to_string()));
assert_eq!(state.resolved_packages.len(), 1);
assert!(state.has_package("nodejs"));
}
#[test]
fn test_remove_resolved_package() {
let mut state = PackageState::default();
let pkg = ResolvedNixpkgPackage {
name: "nodejs".to_string(),
version_spec: Some("20".to_string()),
resolved_version: "20.11.0".to_string(),
attribute_path: "nodejs_20".to_string(),
commit_hash: "abc123".to_string(),
platforms: None,
};
state.add_resolved_package(pkg);
assert!(state.remove_package("nodejs"));
assert!(!state.has_package("nodejs"));
assert!(state.resolved_packages.is_empty());
}
#[test]
fn test_is_legacy_package() {
let mut state = PackageState::default();
state.add_package("legacy-pkg");
state.add_resolved_package(ResolvedNixpkgPackage {
name: "resolved-pkg".to_string(),
version_spec: None,
resolved_version: "1.0.0".to_string(),
attribute_path: "resolved-pkg".to_string(),
commit_hash: "abc123".to_string(),
platforms: None,
});
assert!(state.is_legacy_package("legacy-pkg"));
assert!(!state.is_legacy_package("resolved-pkg"));
}
#[test]
fn test_migration_from_v1() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("packages.json");
let v1_content = r#"{"version":1,"packages":["ripgrep","fzf"],"custom_packages":[]}"#;
fs::write(&path, v1_content).unwrap();
let state = PackageState::load(&path).unwrap();
assert_eq!(state.version, 2);
assert_eq!(state.packages, vec!["ripgrep", "fzf"]);
assert!(state.resolved_packages.is_empty());
}
#[test]
fn test_normalize_platforms_darwin_alias() {
let result = normalize_platforms(&["darwin".to_string()]).unwrap();
assert_eq!(result, vec!["aarch64-darwin", "x86_64-darwin"]);
}
#[test]
fn test_normalize_platforms_macos_alias() {
let result = normalize_platforms(&["macos".to_string()]).unwrap();
assert_eq!(result, vec!["aarch64-darwin", "x86_64-darwin"]);
}
#[test]
fn test_normalize_platforms_linux_alias() {
let result = normalize_platforms(&["linux".to_string()]).unwrap();
assert_eq!(result, vec!["aarch64-linux", "x86_64-linux"]);
}
#[test]
fn test_normalize_platforms_full_name() {
let result = normalize_platforms(&["x86_64-darwin".to_string()]).unwrap();
assert_eq!(result, vec!["x86_64-darwin"]);
}
#[test]
fn test_normalize_platforms_case_insensitive() {
let result = normalize_platforms(&["Darwin".to_string()]).unwrap();
assert_eq!(result, vec!["aarch64-darwin", "x86_64-darwin"]);
let result = normalize_platforms(&["X86_64-LINUX".to_string()]).unwrap();
assert_eq!(result, vec!["x86_64-linux"]);
}
#[test]
fn test_normalize_platforms_dedup() {
let result =
normalize_platforms(&["darwin".to_string(), "x86_64-darwin".to_string()]).unwrap();
assert_eq!(result, vec!["aarch64-darwin", "x86_64-darwin"]);
}
#[test]
fn test_normalize_platforms_invalid() {
let result = normalize_platforms(&["windows".to_string()]);
assert!(result.is_err());
let err_msg = result.unwrap_err();
assert!(err_msg.contains("Invalid platform"));
}
#[test]
fn test_normalize_platforms_mixed() {
let result =
normalize_platforms(&["darwin".to_string(), "x86_64-linux".to_string()]).unwrap();
assert_eq!(
result,
vec!["aarch64-darwin", "x86_64-darwin", "x86_64-linux"]
);
}
}