use crate::flake::interfaces::utils::INDENT_OUT;
use anyhow::{Context, Result};
use clap::builder::OsStr;
use nom::{
branch::alt,
bytes::complete::{tag, take_until, take_while, take_while1},
character::complete::{char, multispace0, space0},
combinator::{opt, recognize},
sequence::{delimited, preceded},
IResult, Parser,
};
use std::{env, fs, path::PathBuf};
pub fn ws(input: &str) -> IResult<&str, &str> {
space0(input)
}
pub fn multiws(input: &str) -> IResult<&str, &str> {
multispace0(input)
}
pub fn identifier(input: &str) -> IResult<&str, &str> {
recognize(take_while1(|c: char| {
c.is_alphanumeric() || c == '_' || c == '-'
}))
.parse(input)
}
pub fn attribute_path_token(input: &str) -> IResult<&str, &str> {
recognize(take_while1(|c: char| {
c.is_alphanumeric() || c == '_' || c == '-' || c == '.'
}))
.parse(input)
}
pub fn pkgs_suffix(input: &str) -> IResult<&str, &str> {
preceded(
tag("pkgs."),
alt((
preceded(char('"'), take_while1(|c: char| c != '"' && c != '@')),
attribute_path_token,
)),
)
.parse(input)
}
pub fn attribute_version(input: &str) -> IResult<&str, &str> {
preceded(
char('@'),
take_while1(|c: char| c.is_alphanumeric() || c == '.' || c == '_' || c == '-'),
)
.parse(input)
}
pub fn opt_attribute_version(input: &str) -> IResult<&str, Option<&str>> {
opt(attribute_version).parse(input)
}
pub fn string_literal(input: &str) -> IResult<&str, &str> {
delimited(char('"'), take_until("\""), char('"')).parse(input)
}
pub fn multiline_string(input: &str) -> IResult<&str, &str> {
delimited(tag("''"), take_until("''"), tag("''")).parse(input)
}
pub fn inline_comment(input: &str) -> IResult<&str, &str> {
preceded((ws, char('#')), take_while(|c| c != '\n')).parse(input)
}
pub fn opt_inline_comment(input: &str) -> IResult<&str, Option<&str>> {
opt(inline_comment).parse(input)
}
pub fn detect_indentation(content: &str) -> String {
for line in content.lines() {
if !line.trim().is_empty() {
let indent = line.len() - line.trim_start().len();
if indent > 0 {
return line[..indent].to_string();
}
}
}
INDENT_OUT.to_string()
}
pub fn _find_position(original: &str, substring: &str) -> Option<usize> {
Some(original.as_ptr() as usize - substring.as_ptr() as usize)
}
pub fn byte_offset(original: &str, remaining: &str) -> usize {
remaining.as_ptr() as usize - original.as_ptr() as usize
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_identifier() {
assert_eq!(identifier("rust-analyzer").unwrap().1, "rust-analyzer");
assert_eq!(identifier("my_var").unwrap().1, "my_var");
}
#[test]
fn test_attribute_path() {
assert_eq!(
attribute_path_token("rust-bin.stable.latest.default")
.unwrap()
.1,
"rust-bin.stable.latest.default"
);
}
#[test]
fn test_string_literal() {
assert_eq!(string_literal("\"hello world\"").unwrap().1, "hello world");
}
#[test]
fn test_inline_comment() {
assert_eq!(
inline_comment("# This is a comment").unwrap().1,
" This is a comment"
);
}
#[test]
fn test_normalize_profile_ref_simple_name() {
assert_eq!(normalize_profile_ref("rust"), Some("rust".to_string()));
assert_eq!(
normalize_profile_ref("my-profile"),
Some("my-profile".to_string())
);
}
#[test]
fn test_normalize_profile_ref_with_dot_hash_prefix() {
assert_eq!(normalize_profile_ref(".#rust"), Some("rust".to_string()));
assert_eq!(
normalize_profile_ref(".#my-profile"),
Some("my-profile".to_string())
);
}
#[test]
fn test_normalize_profile_ref_with_path_hash() {
assert_eq!(
normalize_profile_ref("/path/to/flake#rust"),
Some("rust".to_string())
);
assert_eq!(
normalize_profile_ref("github:user/repo#profile"),
Some("profile".to_string())
);
}
#[test]
fn test_normalize_profile_ref_empty_inputs() {
assert_eq!(normalize_profile_ref(""), None);
assert_eq!(normalize_profile_ref(" "), None);
assert_eq!(normalize_profile_ref("."), None);
assert_eq!(normalize_profile_ref(".#"), None);
}
#[test]
fn test_normalize_profile_ref_trims_whitespace() {
assert_eq!(normalize_profile_ref(" rust "), Some("rust".to_string()));
assert_eq!(
normalize_profile_ref(" .#rust "),
Some("rust".to_string())
);
}
#[test]
fn test_is_valid_profile_name_valid() {
assert!(is_valid_profile_name("rust"));
assert!(is_valid_profile_name("my-profile"));
assert!(is_valid_profile_name("profile_1"));
assert!(is_valid_profile_name("Profile123"));
}
#[test]
fn test_is_valid_profile_name_invalid() {
assert!(!is_valid_profile_name(""));
assert!(!is_valid_profile_name("../etc"));
assert!(!is_valid_profile_name("path/traversal"));
assert!(!is_valid_profile_name("with spaces"));
assert!(!is_valid_profile_name("."));
assert!(!is_valid_profile_name(".."));
assert!(!is_valid_profile_name("pro\\file"));
}
}
pub fn get_default_shell_profile() -> Result<String> {
let content = fs::read_to_string(".flk/default.nix")
.context("Failed to read .flk/default.nix. Have you run 'flk init'?")?;
if let Some(default_start) = content.find("defaultShell = \"") {
let search_start = default_start + "defaultShell = \"".len();
if let Some(end) = content[search_start..].find('"') {
let value = content[search_start..search_start + end].to_string();
if !value.trim().is_empty() {
if !is_valid_profile_name(&value) {
anyhow::bail!(
"Invalid profile name '{}' in default.nix. Profile names must be alphanumeric (with - or _) and cannot contain path separators.",
value
);
}
return Ok(value);
}
}
}
get_first_profile_name()
}
pub fn resolve_profile(target: Option<String>) -> Result<String> {
let profile = if let Some(p) = target.and_then(|p| normalize_profile_ref(&p)) {
p
} else if let Ok(env_profile) = env::var("FLK_FLAKE_REF") {
match normalize_profile_ref(&env_profile) {
Some(p) => p,
None => {
return get_default_shell_profile()
.context("Could not find default shell profile. Run 'flk init' to create one")
}
}
} else {
return get_default_shell_profile()
.context("Could not find default shell profile. Run 'flk init' to create one");
};
if !is_valid_profile_name(&profile) {
anyhow::bail!(
"Invalid profile name '{}'. Profile names must be alphanumeric (with - or _) and cannot contain path separators.",
profile
);
}
Ok(profile)
}
fn normalize_profile_ref(profile: &str) -> Option<String> {
let trimmed = profile.trim();
if trimmed.is_empty() || trimmed == "." || trimmed == ".#" {
return None;
}
if let Some(stripped) = trimmed.strip_prefix(".#") {
if stripped.is_empty() {
return None;
}
return Some(stripped.to_string());
}
if let Some((_, profile_name)) = trimmed.rsplit_once('#') {
if !profile_name.trim().is_empty() {
return Some(profile_name.to_string());
}
}
Some(trimmed.to_string())
}
pub fn is_valid_profile_name(name: &str) -> bool {
!name.is_empty()
&& name != "."
&& name != ".."
&& !name.contains('/')
&& !name.contains('\\')
&& !name.contains(' ')
&& name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
}
fn get_first_profile_name() -> Result<String> {
let profiles = list_profiles()?;
if let Some(first_profile) = profiles.first() {
if let Some(name) = first_profile.file_stem() {
let name_str = name.to_string_lossy().to_string();
if !is_valid_profile_name(&name_str) {
anyhow::bail!(
"Invalid profile name '{}'. Profile names must be alphanumeric (with - or _) and cannot contain path separators.",
name_str
);
}
return Ok(name_str);
}
}
Err(anyhow::anyhow!(
"No profiles found in .flk/profiles/. Run 'flk init' to create a profile"
))
}
pub fn list_profiles() -> Result<Vec<PathBuf>> {
Ok(std::fs::read_dir(".flk/profiles")
.context("Failed to read profiles directory")?
.filter_map(|e| e.ok())
.filter(|e| e.path().extension() == Some(&OsStr::from("nix")))
.filter(|e| e.path().file_name() != Some(&OsStr::from("default.nix")))
.map(|e| e.path())
.collect())
}