use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FlakeSource {
Path(PathBuf),
GitHub {
owner: String,
repo: String,
rev: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FlakeRef {
pub source: FlakeSource,
pub attribute: String,
}
impl FlakeSource {
#[must_use]
pub fn locked_input(&self) -> Option<crate::flake::LockedInput> {
match self {
Self::Path(_) => None,
Self::GitHub { owner, repo, rev } => Some(crate::flake::LockedInput {
source_type: "github".to_owned(),
owner: Some(owner.clone()),
repo: Some(repo.clone()),
rev: Some(rev.clone()),
..Default::default()
}),
}
}
}
impl FlakeRef {
#[must_use]
pub fn local_dir(&self) -> Option<&std::path::Path> {
match &self.source {
FlakeSource::Path(p) => Some(p.as_path()),
FlakeSource::GitHub { .. } => None,
}
}
pub fn parse(input: &str) -> Result<Self, FlakeRefError> {
if let Some((path_part, attr)) = input.split_once('#') {
if let Some(spec) = path_part.strip_prefix("github:") {
if spec.contains('?') {
return Err(FlakeRefError::UnsupportedRemote(path_part.to_owned()));
}
let parts: Vec<&str> = spec.split('/').collect();
return match parts.as_slice() {
[owner, repo, rev] if !owner.is_empty() && !repo.is_empty() && !rev.is_empty() => {
Ok(Self {
source: FlakeSource::GitHub {
owner: (*owner).to_owned(),
repo: (*repo).to_owned(),
rev: (*rev).to_owned(),
},
attribute: attr.to_owned(),
})
}
[_, _] => Err(FlakeRefError::UnpinnedRemote(path_part.to_owned())),
_ => Err(FlakeRefError::UnsupportedRemote(path_part.to_owned())),
};
}
let dir = if path_part == "." || path_part.is_empty() {
std::env::current_dir()
.map_err(|e| FlakeRefError::InvalidPath(e.to_string()))?
} else {
let raw = path_part.strip_prefix("path:").unwrap_or(path_part);
PathBuf::from(raw)
};
Ok(Self {
source: FlakeSource::Path(dir),
attribute: attr.to_string(),
})
} else {
Err(FlakeRefError::MissingAttribute(input.to_string()))
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum FlakeRefError {
#[error("flake reference missing '#attribute': {0}")]
MissingAttribute(String),
#[error("invalid flake path: {0}")]
InvalidPath(String),
#[error(
"flake reference is not pinned to a commit: {0} \
(expected github:owner/repo/<rev>)"
)]
UnpinnedRemote(String),
#[error("unsupported remote flake reference: {0}")]
UnsupportedRemote(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_the_rev_pinned_github_ref_the_fleet_reconciler_uses() {
let fr = FlakeRef::parse(
"github:pleme-io/nix/97cbcf317c3bf64407528473e48a01db37142f35#cid",
)
.expect("a rev-pinned github ref must parse");
assert_eq!(fr.attribute, "cid");
assert_eq!(
fr.source,
FlakeSource::GitHub {
owner: "pleme-io".to_owned(),
repo: "nix".to_owned(),
rev: "97cbcf317c3bf64407528473e48a01db37142f35".to_owned(),
}
);
assert!(
fr.local_dir().is_none(),
"a remote ref has no directory until it is fetched"
);
}
#[test]
fn a_github_ref_carries_the_locked_input_the_fetcher_already_consumes() {
let fr = FlakeRef::parse("github:pleme-io/nix/deadbeef#cid").unwrap();
let li = fr.source.locked_input().expect("github source must yield one");
assert_eq!(li.source_type, "github");
assert_eq!(li.owner.as_deref(), Some("pleme-io"));
assert_eq!(li.repo.as_deref(), Some("nix"));
assert_eq!(li.rev.as_deref(), Some("deadbeef"));
}
#[test]
fn a_path_source_yields_no_locked_input() {
let fr = FlakeRef::parse("/some/dir#cid").unwrap();
assert!(fr.source.locked_input().is_none(), "a path needs no fetch");
}
#[test]
fn an_unpinned_github_ref_is_refused_rather_than_guessed() {
let err = FlakeRef::parse("github:pleme-io/nix#cid")
.expect_err("a branch ref must not resolve silently");
assert!(
matches!(err, FlakeRefError::UnpinnedRemote(_)),
"must name the reason: {err}"
);
}
#[test]
fn a_query_bearing_github_ref_is_refused_not_ignored() {
let err = FlakeRef::parse("github:pleme-io/nix/deadbeef?dir=sub#cid")
.expect_err("an unsupported query must not be dropped");
assert!(matches!(err, FlakeRefError::UnsupportedRemote(_)), "{err}");
}
#[test]
fn parse_dot_hash_cid() {
let fr = FlakeRef::parse(".#cid").unwrap();
assert_eq!(fr.local_dir().unwrap().to_path_buf(), std::env::current_dir().unwrap());
assert_eq!(fr.attribute, "cid");
}
#[test]
fn parse_absolute_path() {
let fr = FlakeRef::parse("/absolute/path#attr").unwrap();
assert_eq!(fr.local_dir().unwrap().to_path_buf(), PathBuf::from("/absolute/path"));
assert_eq!(fr.attribute, "attr");
}
#[test]
fn parse_relative_path() {
let fr = FlakeRef::parse("relative/path#attr").unwrap();
assert_eq!(fr.local_dir().unwrap().to_path_buf(), PathBuf::from("relative/path"));
assert_eq!(fr.attribute, "attr");
}
#[test]
fn parse_missing_hash_returns_error() {
let err = FlakeRef::parse("no-hash-here").unwrap_err();
assert!(matches!(err, FlakeRefError::MissingAttribute(_)));
assert!(err.to_string().contains("no-hash-here"));
}
#[test]
fn parse_empty_attribute_allowed() {
let fr = FlakeRef::parse(".#").unwrap();
assert_eq!(fr.attribute, "");
}
#[test]
fn parse_empty_path_uses_cwd() {
let fr = FlakeRef::parse("#attr").unwrap();
assert_eq!(fr.local_dir().unwrap().to_path_buf(), std::env::current_dir().unwrap());
assert_eq!(fr.attribute, "attr");
}
#[test]
fn parse_dotted_attribute() {
let fr = FlakeRef::parse("/nix#darwinConfigurations.cid.system").unwrap();
assert_eq!(fr.local_dir().unwrap().to_path_buf(), PathBuf::from("/nix"));
assert_eq!(fr.attribute, "darwinConfigurations.cid.system");
}
#[test]
fn parse_strips_path_scheme() {
let fr = FlakeRef::parse("path:/etc/nixos#nixosConfigurations.rio").unwrap();
assert_eq!(fr.local_dir().unwrap().to_path_buf(), PathBuf::from("/etc/nixos"));
assert_eq!(fr.attribute, "nixosConfigurations.rio");
}
#[test]
fn parse_strips_path_scheme_relative() {
let fr = FlakeRef::parse("path:./config#attr").unwrap();
assert_eq!(fr.local_dir().unwrap().to_path_buf(), PathBuf::from("./config"));
assert_eq!(fr.attribute, "attr");
}
#[test]
fn parse_multiple_hashes_splits_on_first() {
let fr = FlakeRef::parse("/path#attr#extra").unwrap();
assert_eq!(fr.local_dir().unwrap().to_path_buf(), PathBuf::from("/path"));
assert_eq!(fr.attribute, "attr#extra");
}
#[test]
fn error_display_missing_attribute() {
let err = FlakeRefError::MissingAttribute("foo".into());
assert!(err.to_string().contains("missing '#attribute'"));
assert!(err.to_string().contains("foo"));
}
#[test]
fn error_display_invalid_path() {
let err = FlakeRefError::InvalidPath("bad path".into());
assert!(err.to_string().contains("invalid flake path"));
assert!(err.to_string().contains("bad path"));
}
}