use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use thiserror::Error;
use crate::config::HyperforgeConfig;
use crate::git::Git;
use crate::types::Repo;
#[derive(Debug, Error)]
pub enum WorkspaceError {
#[error("Workspace path does not exist: {path}")]
PathNotFound { path: PathBuf },
#[error("Workspace path is not a directory: {path}")]
NotADirectory { path: PathBuf },
#[error("Failed to read workspace directory: {0}")]
IoError(#[from] std::io::Error),
}
pub type WorkspaceResult<T> = Result<T, WorkspaceError>;
#[derive(Debug, Clone)]
pub struct DiscoveredRepo {
pub path: PathBuf,
pub dir_name: String,
pub config: Option<HyperforgeConfig>,
pub is_git_repo: bool,
pub is_hyperforge_repo: bool,
}
impl DiscoveredRepo {
pub fn org(&self) -> Option<&str> {
self.config.as_ref().and_then(|c| c.org.as_deref())
}
pub fn forges(&self) -> Vec<&str> {
self.config
.as_ref()
.map(|c| c.forges.iter().map(|f| f.as_str()).collect())
.unwrap_or_default()
}
}
#[derive(Debug, Clone)]
pub struct WorkspaceContext {
pub root: PathBuf,
pub repos: Vec<DiscoveredRepo>,
pub orgs: Vec<String>,
pub forges: Vec<String>,
pub unconfigured_repos: Vec<PathBuf>,
pub skipped_dirs: Vec<PathBuf>,
}
impl WorkspaceContext {
pub fn repos_for_org(&self, org: &str) -> Vec<&DiscoveredRepo> {
self.repos
.iter()
.filter(|r| r.org() == Some(org))
.collect()
}
pub fn repos_for_org_and_forge(&self, org: &str, forge: &str) -> Vec<&DiscoveredRepo> {
self.repos
.iter()
.filter(|r| {
r.org() == Some(org)
&& r.config
.as_ref()
.map(|c| c.forges.iter().any(|f| f == forge))
.unwrap_or(false)
})
.collect()
}
pub fn org_forge_pairs(&self) -> Vec<(String, String)> {
let mut pairs = BTreeSet::new();
for repo in &self.repos {
if let Some(config) = &repo.config {
if let Some(org) = &config.org {
for forge in &config.forges {
pairs.insert((org.clone(), forge.clone()));
}
}
}
}
pairs.into_iter().collect()
}
}
pub fn repo_from_config(discovered: &DiscoveredRepo) -> Option<Repo> {
let config = discovered.config.as_ref()?;
let _org = config.org.as_ref()?;
let parsed_forges: Vec<_> = config
.forges
.iter()
.filter_map(|f| HyperforgeConfig::parse_forge(f))
.collect();
if parsed_forges.is_empty() {
return None;
}
let origin = parsed_forges[0].clone();
let mirrors: Vec<_> = parsed_forges[1..].to_vec();
let repo_name = config.get_repo_name(&discovered.path);
let mut repo = Repo::new(repo_name, origin)
.with_visibility(config.visibility.clone())
.with_mirrors(mirrors);
if let Some(ref desc) = config.description {
repo = repo.with_description(desc);
}
Some(repo)
}
pub fn discover_workspace(workspace_path: &Path) -> WorkspaceResult<WorkspaceContext> {
let workspace_path = workspace_path
.canonicalize()
.map_err(|_| WorkspaceError::PathNotFound {
path: workspace_path.to_path_buf(),
})?;
if !workspace_path.is_dir() {
return Err(WorkspaceError::NotADirectory {
path: workspace_path.clone(),
});
}
let mut repos = Vec::new();
let mut unconfigured_repos = Vec::new();
let mut skipped_dirs = Vec::new();
let mut orgs_set = BTreeSet::new();
let mut forges_set = BTreeSet::new();
let entries = std::fs::read_dir(&workspace_path)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let dir_name = match path.file_name().and_then(|n| n.to_str()) {
Some(name) if name.starts_with('.') => continue,
Some(name) => name.to_string(),
None => continue,
};
let is_git_repo = Git::is_repo(&path);
let is_hyperforge_repo = HyperforgeConfig::exists(&path);
if !is_git_repo && !is_hyperforge_repo {
skipped_dirs.push(path);
continue;
}
if is_git_repo && !is_hyperforge_repo {
unconfigured_repos.push(path);
continue;
}
let config = HyperforgeConfig::load(&path).ok();
if let Some(ref config) = config {
if let Some(ref org) = config.org {
orgs_set.insert(org.clone());
}
for forge in &config.forges {
forges_set.insert(forge.clone());
}
}
repos.push(DiscoveredRepo {
path,
dir_name,
config,
is_git_repo,
is_hyperforge_repo,
});
}
repos.sort_by(|a, b| a.dir_name.cmp(&b.dir_name));
Ok(WorkspaceContext {
root: workspace_path,
repos,
orgs: orgs_set.into_iter().collect(),
forges: forges_set.into_iter().collect(),
unconfigured_repos,
skipped_dirs,
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn setup_workspace() -> TempDir {
let workspace = TempDir::new().unwrap();
let repo_a = workspace.path().join("repo-a");
std::fs::create_dir(&repo_a).unwrap();
Git::init(&repo_a).unwrap();
let config = HyperforgeConfig::new(vec!["github".to_string(), "codeberg".to_string()])
.with_org("alice")
.with_repo_name("repo-a");
config.save(&repo_a).unwrap();
let repo_b = workspace.path().join("repo-b");
std::fs::create_dir(&repo_b).unwrap();
Git::init(&repo_b).unwrap();
let config = HyperforgeConfig::new(vec!["github".to_string()])
.with_org("bob")
.with_repo_name("repo-b");
config.save(&repo_b).unwrap();
let repo_c = workspace.path().join("repo-c");
std::fs::create_dir(&repo_c).unwrap();
Git::init(&repo_c).unwrap();
let random = workspace.path().join("notes");
std::fs::create_dir(&random).unwrap();
let hidden = workspace.path().join(".hidden");
std::fs::create_dir(&hidden).unwrap();
workspace
}
#[test]
fn test_discover_workspace() {
let workspace = setup_workspace();
let ctx = discover_workspace(workspace.path()).unwrap();
assert_eq!(ctx.repos.len(), 2);
assert_eq!(ctx.unconfigured_repos.len(), 1);
assert_eq!(ctx.skipped_dirs.len(), 1);
}
#[test]
fn test_discover_orgs_and_forges() {
let workspace = setup_workspace();
let ctx = discover_workspace(workspace.path()).unwrap();
assert_eq!(ctx.orgs, vec!["alice", "bob"]);
assert_eq!(ctx.forges, vec!["codeberg", "github"]);
}
#[test]
fn test_discover_org_forge_pairs() {
let workspace = setup_workspace();
let ctx = discover_workspace(workspace.path()).unwrap();
let pairs = ctx.org_forge_pairs();
assert_eq!(
pairs,
vec![
("alice".to_string(), "codeberg".to_string()),
("alice".to_string(), "github".to_string()),
("bob".to_string(), "github".to_string()),
]
);
}
#[test]
fn test_discover_repos_for_org() {
let workspace = setup_workspace();
let ctx = discover_workspace(workspace.path()).unwrap();
let alice_repos = ctx.repos_for_org("alice");
assert_eq!(alice_repos.len(), 1);
assert_eq!(alice_repos[0].dir_name, "repo-a");
}
#[test]
fn test_discover_nonexistent_path() {
let result = discover_workspace(Path::new("/nonexistent/path"));
assert!(matches!(result, Err(WorkspaceError::PathNotFound { .. })));
}
#[test]
fn test_repo_from_config_basic() {
let workspace = setup_workspace();
let ctx = discover_workspace(workspace.path()).unwrap();
let repo_a = ctx.repos.iter().find(|r| r.dir_name == "repo-a").unwrap();
let repo = repo_from_config(repo_a).unwrap();
assert_eq!(repo.name, "repo-a");
assert_eq!(repo.origin, crate::types::Forge::GitHub);
assert_eq!(repo.mirrors, vec![crate::types::Forge::Codeberg]);
}
#[test]
fn test_repo_from_config_single_forge() {
let workspace = setup_workspace();
let ctx = discover_workspace(workspace.path()).unwrap();
let repo_b = ctx.repos.iter().find(|r| r.dir_name == "repo-b").unwrap();
let repo = repo_from_config(repo_b).unwrap();
assert_eq!(repo.name, "repo-b");
assert_eq!(repo.origin, crate::types::Forge::GitHub);
assert!(repo.mirrors.is_empty());
}
#[test]
fn test_repo_from_config_no_config() {
let discovered = DiscoveredRepo {
path: PathBuf::from("/tmp/fake"),
dir_name: "fake".to_string(),
config: None,
is_git_repo: true,
is_hyperforge_repo: false,
};
assert!(repo_from_config(&discovered).is_none());
}
#[test]
fn test_repo_from_config_no_org() {
let discovered = DiscoveredRepo {
path: PathBuf::from("/tmp/fake"),
dir_name: "fake".to_string(),
config: Some(HyperforgeConfig::new(vec!["github".to_string()])),
is_git_repo: true,
is_hyperforge_repo: true,
};
assert!(repo_from_config(&discovered).is_none());
}
#[test]
fn test_discover_empty_workspace() {
let workspace = TempDir::new().unwrap();
let ctx = discover_workspace(workspace.path()).unwrap();
assert!(ctx.repos.is_empty());
assert!(ctx.orgs.is_empty());
assert!(ctx.forges.is_empty());
}
}