use anyhow::{Context, Result};
use once_cell::sync::Lazy;
use regex::Regex;
use serde::Deserialize;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use super::GlobalConfig;
static GIT_URL_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
vec![
Regex::new(r"^https?://[^/]+/(?:.+/)?([^/]+?)(?:\.git)?$").unwrap(),
Regex::new(r"^git@[^:]+:(?:.+/)?([^/]+?)(?:\.git)?$").unwrap(),
Regex::new(r"^ssh://[^/]+/(?:.+/)?([^/]+?)(?:\.git)?$").unwrap(),
]
});
static GIT_URL_VALIDATOR: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r"^(?:https?://[^/]+/[^/]+/[^/]+(?:\.git)?|git@[^:]+:[^/]+/[^/]+(?:\.git)?|ssh://[^/]+/[^/]+/[^/]+(?:\.git)?)$"
).unwrap()
});
#[derive(Debug, Deserialize, Clone)]
pub struct Project {
pub name: String,
pub root: String,
pub repo: Option<String>,
#[serde(default)]
pub windows: Vec<Window>,
pub worktree: Option<WorktreeConfig>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
pub enum Window {
Simple(HashMap<String, Option<String>>),
Complex {
#[serde(flatten)]
inner: HashMap<String, WindowConfig>,
},
}
#[derive(Debug, Deserialize, Clone)]
pub struct WindowConfig {
pub layout: Option<String>,
#[serde(default)]
pub panes: Vec<Pane>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
pub enum Pane {
Command(String),
Empty,
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct WorktreeConfig {
#[serde(default)]
pub copy: Vec<String>,
#[serde(default)]
pub symlink: Vec<String>,
#[serde(default)]
pub post_create: Vec<String>,
}
impl Project {
pub fn load(name: &str) -> Result<Self> {
let project_path = GlobalConfig::projects_dir()?.join(format!("{}.yml", name));
if !project_path.exists() {
anyhow::bail!("Project '{}' not found at {:?}", name, project_path);
}
let contents = fs::read_to_string(&project_path)
.with_context(|| format!("Failed to read project: {:?}", project_path))?;
let project: Project = serde_yaml::from_str(&contents)
.with_context(|| format!("Failed to parse project: {:?}", project_path))?;
Ok(project)
}
pub fn list_all() -> Result<Vec<String>> {
let projects_dir = GlobalConfig::projects_dir()?;
if !projects_dir.exists() {
return Ok(vec![]);
}
let mut projects = Vec::new();
for entry in fs::read_dir(&projects_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().map(|e| e == "yml").unwrap_or(false) {
if let Some(stem) = path.file_stem() {
projects.push(stem.to_string_lossy().to_string());
}
}
}
projects.sort();
Ok(projects)
}
pub fn config_path(name: &str) -> Result<PathBuf> {
Ok(GlobalConfig::projects_dir()?.join(format!("{}.yml", name)))
}
pub fn root_expanded(&self) -> PathBuf {
PathBuf::from(shellexpand::tilde(&self.root).to_string())
}
pub fn worktree_session_name(&self, branch: &str) -> String {
format!("{}__{}", self.name, branch.replace('/', "-"))
}
pub fn delete(name: &str) -> Result<()> {
let path = Self::config_path(name)?;
if path.exists() {
fs::remove_file(&path)
.with_context(|| format!("Failed to delete project: {:?}", path))?;
}
Ok(())
}
pub fn clone_if_needed(&self) -> Result<()> {
let root = self.root_expanded();
if root.exists() {
return Ok(());
}
let repo_url = match &self.repo {
Some(url) => url,
None => anyhow::bail!(
"Project root does not exist: {:?}\nAdd a 'repo' field to clone automatically.",
root
),
};
println!("Cloning {} into {:?}...", repo_url, root);
if let Some(parent) = root.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {:?}", parent))?;
}
let status = Command::new("git")
.args(["clone", repo_url, &root.to_string_lossy()])
.status()
.context("Failed to run git clone")?;
if !status.success() {
anyhow::bail!("git clone failed for {}", repo_url);
}
println!("Cloned successfully.");
Ok(())
}
pub fn name_from_repo_url(url: &str) -> Option<String> {
let url = url.trim();
for pattern in GIT_URL_PATTERNS.iter() {
if let Some(captures) = pattern.captures(url) {
if let Some(name) = captures.get(1) {
let name = name.as_str().to_string();
if !name.is_empty() {
return Some(name);
}
}
}
}
None
}
pub fn is_git_url(s: &str) -> bool {
GIT_URL_VALIDATOR.is_match(s.trim())
}
}
impl Window {
pub fn name(&self) -> String {
match self {
Window::Simple(map) => map.keys().next().cloned().unwrap_or_default(),
Window::Complex { inner } => inner.keys().next().cloned().unwrap_or_default(),
}
}
pub fn simple_command(&self) -> Option<String> {
match self {
Window::Simple(map) => map.values().next().cloned().flatten(),
Window::Complex { .. } => None,
}
}
pub fn panes(&self) -> Vec<Pane> {
match self {
Window::Simple(_) => vec![],
Window::Complex { inner } => inner
.values()
.next()
.map(|c| c.panes.clone())
.unwrap_or_default(),
}
}
pub fn layout(&self) -> Option<String> {
match self {
Window::Simple(_) => None,
Window::Complex { inner } => inner.values().next().and_then(|c| c.layout.clone()),
}
}
pub fn has_panes(&self) -> bool {
matches!(self, Window::Complex { .. })
}
}
impl Pane {
pub fn command(&self) -> Option<&str> {
match self {
Pane::Command(cmd) => Some(cmd),
Pane::Empty => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_name_from_https_url() {
assert_eq!(
Project::name_from_repo_url("https://github.com/user/myrepo.git"),
Some("myrepo".to_string())
);
assert_eq!(
Project::name_from_repo_url("https://github.com/user/myrepo"),
Some("myrepo".to_string())
);
assert_eq!(
Project::name_from_repo_url("https://gitlab.com/org/subgroup/repo.git"),
Some("repo".to_string())
);
}
#[test]
fn test_name_from_ssh_url() {
assert_eq!(
Project::name_from_repo_url("git@github.com:user/myrepo.git"),
Some("myrepo".to_string())
);
assert_eq!(
Project::name_from_repo_url("git@github.com:user/myrepo"),
Some("myrepo".to_string())
);
assert_eq!(
Project::name_from_repo_url("git@gitlab.com:org/subgroup/repo.git"),
Some("repo".to_string())
);
}
#[test]
fn test_name_from_ssh_protocol_url() {
assert_eq!(
Project::name_from_repo_url("ssh://git@github.com/user/myrepo.git"),
Some("myrepo".to_string())
);
assert_eq!(
Project::name_from_repo_url("ssh://git@github.com/user/myrepo"),
Some("myrepo".to_string())
);
}
#[test]
fn test_is_git_url_valid() {
assert!(Project::is_git_url("https://github.com/user/repo.git"));
assert!(Project::is_git_url("https://github.com/user/repo"));
assert!(Project::is_git_url("git@github.com:user/repo.git"));
assert!(Project::is_git_url("git@github.com:user/repo"));
assert!(Project::is_git_url("ssh://git@github.com/user/repo.git"));
}
#[test]
fn test_is_git_url_invalid() {
assert!(!Project::is_git_url("myproject"));
assert!(!Project::is_git_url("some-name"));
assert!(!Project::is_git_url("https://example.com"));
assert!(!Project::is_git_url(""));
}
}