use std::io::IsTerminal;
use std::sync::atomic::{AtomicBool, Ordering};
static NON_INTERACTIVE: AtomicBool = AtomicBool::new(false);
pub fn is_non_interactive() -> bool {
NON_INTERACTIVE.load(Ordering::Relaxed)
}
pub fn set_non_interactive(value: bool) {
NON_INTERACTIVE.store(value, Ordering::Relaxed);
}
pub fn init_non_interactive_from_env() {
if let Ok(raw) = std::env::var("FLEDGE_NON_INTERACTIVE") {
if is_truthy(&raw) {
set_non_interactive(true);
}
}
}
fn is_truthy(s: &str) -> bool {
matches!(
s.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "y" | "on"
)
}
pub fn is_interactive() -> bool {
!is_non_interactive() && std::io::stdin().is_terminal()
}
pub fn require_interactive(flag_name: &str) -> anyhow::Result<()> {
if is_non_interactive() {
anyhow::bail!(
"This command requires interactive input but --non-interactive (or FLEDGE_NON_INTERACTIVE) is set.\n \
Use --{} to skip prompts, provide all required arguments via flags,\n \
or unset FLEDGE_NON_INTERACTIVE / omit --non-interactive to run interactively.",
flag_name
);
}
if !is_interactive() {
anyhow::bail!(
"This command requires interactive input but stdin is not a TTY.\n \
Use --{} to skip prompts, or provide all required arguments via flags.",
flag_name
);
}
Ok(())
}
pub fn to_kebab_case(s: &str) -> String {
s.chars()
.map(|c| {
if c == '_' {
'-'
} else {
c.to_ascii_lowercase()
}
})
.collect()
}
pub fn to_camel_case(s: &str) -> String {
let pascal = to_pascal_case(s);
let mut chars = pascal.chars();
match chars.next() {
None => String::new(),
Some(first) => {
let mut s = first.to_lowercase().to_string();
s.extend(chars);
s
}
}
}
pub fn to_snake_case(s: &str) -> String {
s.chars()
.map(|c| {
if c == '-' {
'_'
} else {
c.to_ascii_lowercase()
}
})
.collect()
}
pub fn to_pascal_case(s: &str) -> String {
s.split(['-', '_'])
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => {
let mut s = first.to_uppercase().to_string();
s.extend(chars);
s
}
}
})
.collect()
}
pub fn validate_project_name(name: &str) -> anyhow::Result<()> {
if name.is_empty() {
anyhow::bail!("Project name cannot be empty");
}
if name.contains('/') || name.contains('\\') || name.contains("..") {
anyhow::bail!("Project name cannot contain path separators or '..'");
}
if name.contains('\0') {
anyhow::bail!("Project name cannot contain null bytes");
}
let reserved = [
"con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8",
"com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
];
if reserved.contains(&name.to_ascii_lowercase().as_str()) {
anyhow::bail!("'{}' is a reserved name on Windows", name);
}
Ok(())
}
pub fn validate_github_org(org: &str) -> anyhow::Result<()> {
if org.is_empty() {
anyhow::bail!("GitHub organization cannot be empty");
}
if org.contains('/') || org.contains('\\') {
anyhow::bail!("GitHub organization cannot contain slashes");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_to_snake_case() {
assert_eq!(to_snake_case("my-project"), "my_project");
assert_eq!(to_snake_case("MyProject"), "myproject");
assert_eq!(to_snake_case("already_snake"), "already_snake");
}
#[test]
fn test_to_snake_case_multiple_hyphens() {
assert_eq!(to_snake_case("my-cool-project"), "my_cool_project");
}
#[test]
fn test_to_snake_case_empty() {
assert_eq!(to_snake_case(""), "");
}
#[test]
fn test_to_snake_case_single_char() {
assert_eq!(to_snake_case("A"), "a");
}
#[test]
fn test_to_pascal_case() {
assert_eq!(to_pascal_case("my-project"), "MyProject");
assert_eq!(to_pascal_case("my_project"), "MyProject");
assert_eq!(to_pascal_case("single"), "Single");
}
#[test]
fn test_to_pascal_case_multiple_segments() {
assert_eq!(to_pascal_case("my-cool-project"), "MyCoolProject");
}
#[test]
fn test_to_pascal_case_mixed_separators() {
assert_eq!(to_pascal_case("my-cool_project"), "MyCoolProject");
}
#[test]
fn test_to_pascal_case_empty() {
assert_eq!(to_pascal_case(""), "");
}
#[test]
fn test_to_pascal_case_single_char() {
assert_eq!(to_pascal_case("a"), "A");
}
#[test]
fn test_to_kebab_case() {
assert_eq!(to_kebab_case("my_project"), "my-project");
assert_eq!(to_kebab_case("my-project"), "my-project");
assert_eq!(to_kebab_case("MyProject"), "myproject");
}
#[test]
fn test_to_kebab_case_empty() {
assert_eq!(to_kebab_case(""), "");
}
#[test]
fn test_to_camel_case() {
assert_eq!(to_camel_case("my-project"), "myProject");
assert_eq!(to_camel_case("my_project"), "myProject");
assert_eq!(to_camel_case("single"), "single");
}
#[test]
fn test_to_camel_case_multiple_segments() {
assert_eq!(to_camel_case("my-cool-project"), "myCoolProject");
}
#[test]
fn test_to_camel_case_empty() {
assert_eq!(to_camel_case(""), "");
}
#[test]
fn test_validate_project_name_valid() {
assert!(validate_project_name("my-project").is_ok());
assert!(validate_project_name("cool_app").is_ok());
}
#[test]
fn test_validate_project_name_empty() {
assert!(validate_project_name("").is_err());
}
#[test]
fn test_validate_project_name_path_traversal() {
assert!(validate_project_name("../escape").is_err());
assert!(validate_project_name("my/project").is_err());
}
#[test]
fn test_validate_project_name_reserved() {
assert!(validate_project_name("con").is_err());
assert!(validate_project_name("NUL").is_err());
}
#[test]
fn test_validate_github_org_valid() {
assert!(validate_github_org("CorvidLabs").is_ok());
assert!(validate_github_org("my-org").is_ok());
}
#[test]
fn test_validate_github_org_empty() {
assert!(validate_github_org("").is_err());
}
#[test]
fn test_validate_github_org_spaces_allowed() {
assert!(validate_github_org("my org").is_ok());
}
#[test]
fn test_validate_github_org_slashes() {
assert!(validate_github_org("my/org").is_err());
}
#[test]
fn test_is_truthy_accepts_common_values() {
for v in ["1", "true", "TRUE", "Yes", "y", "ON", " on "] {
assert!(is_truthy(v), "expected '{v}' to be truthy");
}
}
#[test]
fn test_is_truthy_rejects_common_falsy_values() {
for v in ["", "0", "false", "no", "off", "nope", "blue"] {
assert!(!is_truthy(v), "expected '{v}' to be falsy");
}
}
use std::sync::Mutex;
static NON_INTERACTIVE_TEST_LOCK: Mutex<()> = Mutex::new(());
struct NonInteractiveGuard<'a> {
_lock: std::sync::MutexGuard<'a, ()>,
prev: bool,
}
impl NonInteractiveGuard<'_> {
fn new(set_to: bool) -> Self {
let lock = NON_INTERACTIVE_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let prev = is_non_interactive();
set_non_interactive(set_to);
Self { _lock: lock, prev }
}
}
impl Drop for NonInteractiveGuard<'_> {
fn drop(&mut self) {
set_non_interactive(self.prev);
}
}
#[test]
fn test_set_and_is_non_interactive() {
let _guard = NonInteractiveGuard::new(true);
assert!(is_non_interactive());
set_non_interactive(false);
assert!(!is_non_interactive());
}
#[test]
fn test_require_interactive_bails_when_non_interactive_set() {
let _guard = NonInteractiveGuard::new(true);
let result = require_interactive("yes");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("--non-interactive") || msg.contains("FLEDGE_NON_INTERACTIVE"),
"error should mention the flag or env var, got: {msg}"
);
assert!(
msg.contains("unset FLEDGE_NON_INTERACTIVE") || msg.contains("omit --non-interactive"),
"error should offer escape hatch, got: {msg}"
);
}
#[test]
fn test_is_interactive_respects_non_interactive_flag() {
let _guard = NonInteractiveGuard::new(true);
assert!(
!is_interactive(),
"should be non-interactive when flag is set"
);
}
}