// Project group configuration // Reads/writes ~/.config/bterminal/groups.json // Path configurable via init() for test isolation. use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::sync::OnceLock; static CONFIG_PATH: OnceLock = OnceLock::new(); /// Set the groups.json path. Must be called before any config access. /// Called from lib.rs setup with AppConfig-resolved path. pub fn init(path: PathBuf) { let _ = CONFIG_PATH.set(path); } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProjectConfig { pub id: String, pub name: String, pub identifier: String, pub description: String, pub icon: String, pub cwd: String, pub profile: String, pub enabled: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GroupAgentConfig { pub id: String, pub name: String, pub role: String, #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, #[serde(skip_serializing_if = "Option::is_none")] pub cwd: Option, #[serde(skip_serializing_if = "Option::is_none")] pub system_prompt: Option, pub enabled: bool, #[serde(skip_serializing_if = "Option::is_none")] pub wake_interval_min: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GroupConfig { pub id: String, pub name: String, pub projects: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub agents: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GroupsFile { pub version: u32, pub groups: Vec, pub active_group_id: String, } impl Default for GroupsFile { fn default() -> Self { Self { version: 1, groups: Vec::new(), active_group_id: String::new(), } } } fn config_path() -> PathBuf { CONFIG_PATH.get().cloned().unwrap_or_else(|| { dirs::config_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("bterminal") .join("groups.json") }) } pub fn load_groups() -> Result { let path = config_path(); if !path.exists() { return Ok(GroupsFile::default()); } let content = std::fs::read_to_string(&path) .map_err(|e| format!("Failed to read groups.json: {e}"))?; serde_json::from_str(&content) .map_err(|e| format!("Invalid groups.json: {e}")) } pub fn save_groups(config: &GroupsFile) -> Result<(), String> { let path = config_path(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .map_err(|e| format!("Failed to create config dir: {e}"))?; } let json = serde_json::to_string_pretty(config) .map_err(|e| format!("JSON serialize error: {e}"))?; std::fs::write(&path, json) .map_err(|e| format!("Failed to write groups.json: {e}")) } /// Discover markdown files in a project directory for the Docs tab. /// Returns paths relative to cwd, prioritized: CLAUDE.md, README.md, docs/*.md pub fn discover_markdown_files(cwd: &str) -> Result, String> { let root = PathBuf::from(cwd); if !root.is_dir() { return Err(format!("Directory not found: {cwd}")); } let mut entries = Vec::new(); // Priority files at root for name in &["CLAUDE.md", "README.md", "CHANGELOG.md", "TODO.md", "SETUP.md"] { let path = root.join(name); if path.is_file() { entries.push(MdFileEntry { name: name.to_string(), path: path.to_string_lossy().to_string(), priority: true, }); } } // docs/ or doc/ directory (max 20 entries, depth 2) for dir_name in &["docs", "doc"] { let docs_dir = root.join(dir_name); if docs_dir.is_dir() { scan_md_dir(&docs_dir, &mut entries, 2, 20); } } Ok(entries) } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MdFileEntry { pub name: String, pub path: String, pub priority: bool, } fn scan_md_dir(dir: &PathBuf, entries: &mut Vec, max_depth: u32, max_count: usize) { if max_depth == 0 || entries.len() >= max_count { return; } let Ok(read_dir) = std::fs::read_dir(dir) else { return }; for entry in read_dir.flatten() { if entries.len() >= max_count { break; } let path = entry.path(); if path.is_file() { if let Some(ext) = path.extension() { if ext == "md" || ext == "markdown" { let name = path.file_name().unwrap_or_default().to_string_lossy().to_string(); entries.push(MdFileEntry { name, path: path.to_string_lossy().to_string(), priority: false, }); } } } else if path.is_dir() { // Skip common non-doc directories let dir_name = path.file_name().unwrap_or_default().to_string_lossy().to_string(); if !matches!(dir_name.as_str(), "node_modules" | ".git" | "target" | "dist" | "build" | ".next" | "__pycache__") { scan_md_dir(&path, entries, max_depth - 1, max_count); } } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_default_groups_file() { let g = GroupsFile::default(); assert_eq!(g.version, 1); assert!(g.groups.is_empty()); assert!(g.active_group_id.is_empty()); } #[test] fn test_groups_roundtrip() { let config = GroupsFile { version: 1, groups: vec![GroupConfig { id: "test".to_string(), name: "Test Group".to_string(), projects: vec![ProjectConfig { id: "p1".to_string(), name: "Project One".to_string(), identifier: "project-one".to_string(), description: "A test project".to_string(), icon: "\u{f120}".to_string(), cwd: "/tmp/test".to_string(), profile: "default".to_string(), enabled: true, }], agents: vec![], }], active_group_id: "test".to_string(), }; let json = serde_json::to_string(&config).unwrap(); let parsed: GroupsFile = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.groups.len(), 1); assert_eq!(parsed.groups[0].projects.len(), 1); assert_eq!(parsed.groups[0].projects[0].identifier, "project-one"); } #[test] fn test_load_missing_file_returns_default() { // config_path() will point to a non-existent file in test // We test the default case directly let g = GroupsFile::default(); assert_eq!(g.version, 1); } #[test] fn test_discover_nonexistent_dir() { let result = discover_markdown_files("/nonexistent/path/12345"); assert!(result.is_err()); } #[test] fn test_discover_empty_dir() { let dir = tempfile::tempdir().unwrap(); let result = discover_markdown_files(dir.path().to_str().unwrap()).unwrap(); assert!(result.is_empty()); } #[test] fn test_discover_finds_readme() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("README.md"), "# Hello").unwrap(); let result = discover_markdown_files(dir.path().to_str().unwrap()).unwrap(); assert_eq!(result.len(), 1); assert_eq!(result[0].name, "README.md"); assert!(result[0].priority); } #[test] fn test_discover_finds_docs() { let dir = tempfile::tempdir().unwrap(); let docs = dir.path().join("docs"); std::fs::create_dir(&docs).unwrap(); std::fs::write(docs.join("guide.md"), "# Guide").unwrap(); std::fs::write(docs.join("api.md"), "# API").unwrap(); let result = discover_markdown_files(dir.path().to_str().unwrap()).unwrap(); assert_eq!(result.len(), 2); assert!(result.iter().all(|e| !e.priority)); } #[test] fn test_discover_finds_doc_dir() { let dir = tempfile::tempdir().unwrap(); let doc = dir.path().join("doc"); std::fs::create_dir(&doc).unwrap(); std::fs::write(doc.join("requirements.md"), "# Req").unwrap(); let result = discover_markdown_files(dir.path().to_str().unwrap()).unwrap(); assert_eq!(result.len(), 1); assert_eq!(result[0].name, "requirements.md"); assert!(!result[0].priority); } #[test] fn test_discover_finds_setup_md() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("SETUP.md"), "# Setup").unwrap(); let result = discover_markdown_files(dir.path().to_str().unwrap()).unwrap(); assert_eq!(result.len(), 1); assert_eq!(result[0].name, "SETUP.md"); assert!(result[0].priority); } }