use std::path::{Path, PathBuf};
use std::sync::OnceLock;
static BAMBOO_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
pub fn path_to_display_string(path: &Path) -> String {
let s = path.to_string_lossy().to_string();
#[cfg(windows)]
{
if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
return format!(r"\\{}", rest);
}
if let Some(rest) = s.strip_prefix(r"\\?\") {
return rest.to_string();
}
}
s
}
pub fn resolve_bamboo_dir() -> PathBuf {
std::env::var("BAMBOO_DATA_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| match dirs::home_dir() {
Some(home) => home.join(".bamboo"),
None => PathBuf::from(".bamboo"),
})
}
pub fn init_bamboo_dir(dir: PathBuf) {
let _ = BAMBOO_DATA_DIR.set(dir);
}
pub fn bamboo_dir() -> PathBuf {
BAMBOO_DATA_DIR
.get()
.cloned()
.unwrap_or_else(resolve_bamboo_dir)
}
pub fn bamboo_dir_display() -> String {
path_to_display_string(&bamboo_dir())
}
pub fn config_json_path() -> PathBuf {
bamboo_dir().join("config.json")
}
pub fn keyword_masking_json_path() -> PathBuf {
bamboo_dir().join("keyword_masking.json")
}
pub fn workflows_dir() -> PathBuf {
bamboo_dir().join("workflows")
}
pub fn is_safe_workflow_name(name: &str) -> bool {
if name.is_empty() || name.len() > 255 {
return false;
}
let trimmed = name.trim();
if trimmed != name || trimmed.is_empty() {
return false;
}
if name.contains('/') || name.contains('\\') || name.contains("..") {
return false;
}
if name.chars().any(|ch| ch.is_control() || ch == '\0') {
return false;
}
let upper = name.to_uppercase();
let stem = upper.split('.').next().unwrap_or(&upper);
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(&stem) {
return false;
}
name.chars()
.all(|ch| ch.is_alphanumeric() || ch == '-' || ch == '_' || ch == '.' || ch == ' ')
}
pub fn anthropic_model_mapping_path() -> PathBuf {
bamboo_dir().join("anthropic-model-mapping.json")
}
pub fn gemini_model_mapping_path() -> PathBuf {
bamboo_dir().join("gemini-model-mapping.json")
}
pub fn ensure_bamboo_dir() -> std::io::Result<PathBuf> {
let dir = bamboo_dir();
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
pub fn sessions_dir() -> PathBuf {
bamboo_dir().join("sessions")
}
pub fn events_dir() -> PathBuf {
bamboo_dir().join("events")
}
pub fn load_config_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, String> {
if !path.exists() {
return Err(format!("Config file not found: {}", path.display()));
}
let content =
std::fs::read_to_string(path).map_err(|e| format!("Failed to read config: {e}"))?;
serde_json::from_str(&content).map_err(|e| format!("Failed to parse config: {e}"))
}
pub fn save_config_json<T: serde::Serialize>(path: &Path, value: &T) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {e}"))?;
}
let content = serde_json::to_string_pretty(value)
.map_err(|e| format!("Failed to serialize config: {e}"))?;
std::fs::write(path, content).map_err(|e| format!("Failed to write config: {e}"))
}
pub fn user_settings_path() -> PathBuf {
bamboo_dir().join("settings.json")
}
pub fn project_settings_dir(project_dir: &Path) -> PathBuf {
project_dir.join(".bamboo")
}
pub fn project_settings_path(project_dir: &Path) -> PathBuf {
project_settings_dir(project_dir).join("settings.json")
}
pub fn local_project_settings_path(project_dir: &Path) -> PathBuf {
project_settings_dir(project_dir).join("settings.local.json")
}
pub fn managed_settings_path() -> PathBuf {
#[cfg(target_os = "linux")]
{
PathBuf::from("/etc/bamboo/settings.json")
}
#[cfg(target_os = "macos")]
{
PathBuf::from("/Library/Application Support/Bamboo/settings.json")
}
#[cfg(target_os = "windows")]
{
PathBuf::from("C:\\ProgramData\\Bamboo\\settings.json")
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
PathBuf::from("/etc/bamboo/settings.json")
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, OnceLock};
use tempfile::tempdir;
#[test]
fn is_safe_workflow_name_accepts_allowlisted_names() {
for ok in [
"my-workflow",
"workflow_123",
"My Workflow",
"test.workflow",
"workflow.md",
"v1.0",
"2024-01-15",
"a",
"工作流",
"ワークフロー",
"العربية",
] {
assert!(is_safe_workflow_name(ok), "{ok:?} should be accepted");
}
}
#[test]
fn is_safe_workflow_name_rejects_unsafe_names() {
for bad in [
"", "workflow/name", "/workflow", "workflow\\name", "..", "../workflow", "workflow..test", "workflow (v1)", "workflow [test]", "workflow@2.0", "workflow#1", "workflow$var", "workflow*", "workflow+test", "🚀-workflow", " workflow", "workflow ", "\tworkflow", "work\u{0}flow", "CON", "nul.md", ] {
assert!(!is_safe_workflow_name(bad), "{bad:?} should be rejected");
}
assert!(!is_safe_workflow_name(&"a".repeat(256)));
}
#[test]
fn test_resolve_bamboo_dir_prefers_env() {
let _guard = crate::test_support::env_cache_lock_acquire();
let temp_dir = tempdir().expect("Failed to create temp dir");
let bamboo_home = temp_dir.path().to_string_lossy().to_string();
let original = std::env::var_os("BAMBOO_DATA_DIR");
std::env::set_var("BAMBOO_DATA_DIR", &bamboo_home);
assert_eq!(resolve_bamboo_dir(), PathBuf::from(&bamboo_home));
if let Some(val) = original {
std::env::set_var("BAMBOO_DATA_DIR", val);
} else {
std::env::remove_var("BAMBOO_DATA_DIR");
}
}
#[test]
fn test_sessions_dir_is_under_bamboo_dir() {
assert_eq!(sessions_dir(), bamboo_dir().join("sessions"));
}
#[test]
fn test_config_json_path() {
let path = config_json_path();
assert!(path.ends_with("config.json"));
assert!(path.parent().is_some());
}
#[test]
fn test_keyword_masking_json_path() {
let path = keyword_masking_json_path();
assert!(path.ends_with("keyword_masking.json"));
}
#[test]
fn test_workflows_dir() {
let path = workflows_dir();
assert!(path.ends_with("workflows"));
}
#[test]
fn test_anthropic_model_mapping_path() {
let path = anthropic_model_mapping_path();
assert!(path.ends_with("anthropic-model-mapping.json"));
}
#[test]
fn test_gemini_model_mapping_path() {
let path = gemini_model_mapping_path();
assert!(path.ends_with("gemini-model-mapping.json"));
}
#[test]
fn test_ensure_bamboo_dir_creates_directory() {
let temp_dir = tempdir().expect("Failed to create temp dir");
let test_dir = temp_dir.path().join("test_bamboo");
let _guard = crate::test_support::env_cache_lock_acquire();
let original = std::env::var_os("BAMBOO_DATA_DIR");
std::env::set_var("BAMBOO_DATA_DIR", &test_dir);
let result = ensure_bamboo_dir();
assert!(result.is_ok());
assert!(test_dir.exists());
if let Some(val) = original {
std::env::set_var("BAMBOO_DATA_DIR", val);
} else {
std::env::remove_var("BAMBOO_DATA_DIR");
}
}
#[test]
fn test_load_config_json_missing_file() {
let result: Result<String, _> = load_config_json(Path::new("/nonexistent/file.json"));
assert!(result.is_err());
assert!(result.unwrap_err().contains("Config file not found"));
}
#[test]
fn test_load_config_json_valid_file() {
let temp_dir = tempdir().expect("Failed to create temp dir");
let file_path = temp_dir.path().join("test.json");
std::fs::write(&file_path, r#"{"key": "value"}"#).expect("Failed to write file");
#[derive(serde::Deserialize)]
struct TestConfig {
key: String,
}
let result: Result<TestConfig, _> = load_config_json(&file_path);
assert!(result.is_ok());
let config = result.unwrap();
assert_eq!(config.key, "value");
}
#[test]
fn test_load_config_json_invalid_json() {
let temp_dir = tempdir().expect("Failed to create temp dir");
let file_path = temp_dir.path().join("invalid.json");
std::fs::write(&file_path, "not valid json").expect("Failed to write file");
let result: Result<String, _> = load_config_json(&file_path);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Failed to parse config"));
}
#[test]
fn test_save_config_json_creates_file() {
let temp_dir = tempdir().expect("Failed to create temp dir");
let file_path = temp_dir.path().join("new_config.json");
#[derive(serde::Serialize)]
struct TestConfig {
key: String,
}
let config = TestConfig {
key: "value".to_string(),
};
let result = save_config_json(&file_path, &config);
assert!(result.is_ok());
assert!(file_path.exists());
let content = std::fs::read_to_string(&file_path).expect("Failed to read file");
assert!(content.contains("key"));
assert!(content.contains("value"));
}
#[test]
fn test_save_config_json_creates_parent_directory() {
let temp_dir = tempdir().expect("Failed to create temp dir");
let file_path = temp_dir.path().join("subdir/nested/config.json");
#[derive(serde::Serialize)]
struct TestConfig {
key: String,
}
let config = TestConfig {
key: "value".to_string(),
};
let result = save_config_json(&file_path, &config);
assert!(result.is_ok());
assert!(file_path.exists());
}
#[test]
fn test_path_to_display_string_simple() {
let path = Path::new("/home/user/test");
let result = path_to_display_string(path);
assert_eq!(result, "/home/user/test");
}
#[test]
fn test_path_to_display_string_empty() {
let path = Path::new("");
let result = path_to_display_string(path);
assert_eq!(result, "");
}
#[test]
fn test_bamboo_dir_display() {
let result = bamboo_dir_display();
assert!(!result.is_empty());
}
#[test]
fn test_init_bamboo_dir_first_call_wins() {
static INIT_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
let _guard = INIT_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("INIT_LOCK poisoned");
static TEST_DIR: OnceLock<PathBuf> = OnceLock::new();
let first = PathBuf::from("/first/path");
let second = PathBuf::from("/second/path");
let _ = TEST_DIR.set(first.clone());
let result = TEST_DIR.set(second);
assert!(result.is_err());
assert_eq!(TEST_DIR.get().unwrap(), &first);
}
}