Vectrum
Sign In
256 lines · 10.1 KB · Rust
//! Where a new project comes from, as the Add project palette offers it: a
//! folder already on disk, a new empty repository, or a clone. A host runs
//! the same code when a connected device adds a project on it.

use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

use serde::{Deserialize, Serialize};

use crate::ui::folder_browser::expand;
use crate::ui::workspace::find_program;

/// A hosted Git service to clone `owner/repo` from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Forge {
    GitHub,
    GitLab,
    AzureDevOps,
    Bitbucket,
    Forgejo,
}

impl Forge {
    pub const ALL: [Forge; 5] = [Forge::GitHub, Forge::GitLab, Forge::AzureDevOps, Forge::Bitbucket, Forge::Forgejo];

    pub fn label(self) -> &'static str {
        match self {
            Forge::GitHub => "GitHub repository",
            Forge::GitLab => "GitLab repository",
            Forge::AzureDevOps => "Azure DevOps repository",
            Forge::Bitbucket => "Bitbucket repository",
            Forge::Forgejo => "Forgejo / Gitea repository",
        }
    }

    /// What the clone field asks for.
    pub fn placeholder(self) -> &'static str {
        match self {
            Forge::AzureDevOps => "organization/project/repo",
            Forge::Bitbucket => "workspace/repo",
            _ => "owner/repo",
        }
    }

    pub fn description(self) -> String {
        match self {
            Forge::GitHub => "Clone a GitHub owner/repo".into(),
            Forge::GitLab => "Clone a GitLab group/repo".into(),
            Forge::AzureDevOps => "Clone Azure DevOps project/repository".into(),
            Forge::Bitbucket => "Clone Bitbucket workspace/repository".into(),
            Forge::Forgejo => "Clone Forgejo / Gitea owner/repo".into(),
        }
    }

    /// The command-line tool that signs in to it, if cloning needs one.
    pub fn cli(self) -> Option<&'static str> {
        match self {
            Forge::GitHub => Some("gh"),
            Forge::GitLab => Some("glab"),
            Forge::Forgejo => Some("tea"),
            Forge::AzureDevOps | Forge::Bitbucket => None,
        }
    }

    /// What to do before it can clone, when its tool is missing.
    pub fn setup_hint(self) -> &'static str {
        match self {
            Forge::GitHub => "Install the GitHub CLI (gh) and run gh auth login.",
            Forge::GitLab => "Install the GitLab CLI (glab) and run glab auth login.",
            Forge::Forgejo => "Install the Gitea CLI (tea) and run tea login add.",
            Forge::AzureDevOps | Forge::Bitbucket => "",
        }
    }

    /// Whether its tool is installed here.
    pub fn ready(self) -> bool {
        self.cli().is_none_or(|cli| find_program(&[cli]).is_some())
    }
}

/// What the user picked in the Add project palette.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ProjectSource {
    /// A folder that's already there, as typed: `~/Work/app`.
    Folder { path: String },
    /// A new folder in the base directory, with an empty repository.
    New { name: String },
    /// A clone of a Git URL.
    Url { url: String },
    /// A clone of a repository on a forge.
    Forge { forge: Forge, repo: String },
}

impl ProjectSource {
    /// Clones take a while, so they run in the background.
    pub fn is_clone(&self) -> bool {
        matches!(self, ProjectSource::Url { .. } | ProjectSource::Forge { .. })
    }

    /// The folder a clone or new project lands in, under `base`.
    pub fn destination(&self, base: &Path) -> Option<PathBuf> {
        let name = match self {
            ProjectSource::Folder { .. } => return None,
            ProjectSource::New { name } => name.trim().to_string(),
            ProjectSource::Url { url } => repo_name(url)?,
            ProjectSource::Forge { repo, .. } => repo_name(repo)?,
        };
        Some(base.join(name))
    }
}

/// The folder a repository clones into: its URL's last part, without `.git`.
pub fn repo_name(url: &str) -> Option<String> {
    let url = url.trim().trim_end_matches('/');
    let last = url.rsplit(['/', ':']).next()?;
    let name = last.strip_suffix(".git").unwrap_or(last);
    (!name.is_empty() && name != "." && name != "..").then(|| name.to_string())
}

/// Why a new project's name won't do, if it won't.
pub fn check_name(name: &str) -> Option<&'static str> {
    let name = name.trim();
    if name.is_empty() {
        Some("Type a name for the project.")
    } else if name == "." || name == ".." || name.contains('/') || name.contains('\\') {
        Some("A project name can't contain slashes.")
    } else {
        None
    }
}

/// Makes the source a project folder on this device and returns it. New
/// projects and clones go in `base`. Clones block until git is done.
pub fn resolve(source: &ProjectSource, base: &Path, home: &Path) -> Result<PathBuf, String> {
    match source {
        ProjectSource::Folder { path } => {
            let path = expand(path.trim(), home);
            if path.is_dir() { Ok(path) } else { Err(format!("{} isn't a folder.", path.display())) }
        }
        ProjectSource::New { name } => {
            if let Some(problem) = check_name(name) {
                return Err(problem.into());
            }
            let path = base.join(name.trim());
            if path.exists() {
                return Err(format!("{} already exists.", path.display()));
            }
            std::fs::create_dir_all(&path).map_err(|err| format!("Couldn't create {}: {err}", path.display()))?;
            if let Some(git) = find_program(&["git"]) {
                run(Command::new(git).arg("init").arg("--quiet").arg(&path))?;
            }
            Ok(path)
        }
        ProjectSource::Url { url } => {
            let dest = clone_destination(source, base)?;
            let git = find_program(&["git"]).ok_or("Git isn't installed.")?;
            run(Command::new(git).args(["clone", "--quiet", "--", url.trim()]).arg(&dest))?;
            Ok(dest)
        }
        ProjectSource::Forge { forge, repo } => {
            let dest = clone_destination(source, base)?;
            let repo = repo.trim().trim_matches('/');
            let mut command = match forge.cli() {
                Some(cli) => {
                    let program = find_program(&[cli]).ok_or_else(|| forge.setup_hint().to_string())?;
                    let mut command = Command::new(program);
                    match forge {
                        Forge::Forgejo => command.args(["clone", repo]).arg(&dest),
                        _ => command.args(["repo", "clone", repo]).arg(&dest),
                    };
                    command
                }
                None => {
                    let url = match forge {
                        Forge::AzureDevOps => {
                            let parts: Vec<&str> = repo.split('/').collect();
                            let [org, project, name] = parts.as_slice() else {
                                return Err("Type organization/project/repo.".into());
                            };
                            format!("https://dev.azure.com/{org}/{project}/_git/{name}")
                        }
                        _ => format!("https://bitbucket.org/{repo}.git"),
                    };
                    let git = find_program(&["git"]).ok_or("Git isn't installed.")?;
                    let mut command = Command::new(git);
                    command.args(["clone", "--quiet", "--", &url]).arg(&dest);
                    command
                }
            };
            run(&mut command)?;
            Ok(dest)
        }
    }
}

fn clone_destination(source: &ProjectSource, base: &Path) -> Result<PathBuf, String> {
    let dest = source.destination(base).ok_or("That doesn't look like a repository.")?;
    if dest.exists() {
        return Err(format!("{} already exists.", dest.display()));
    }
    std::fs::create_dir_all(base).map_err(|err| format!("Couldn't create {}: {err}", base.display()))?;
    Ok(dest)
}

/// Runs a command without a terminal, so a missing login fails instead of
/// waiting for a password no one can type.
fn run(command: &mut Command) -> Result<(), String> {
    command.stdin(Stdio::null()).env("GIT_TERMINAL_PROMPT", "0");
    if std::env::var_os("GIT_SSH_COMMAND").is_none() {
        command.env("GIT_SSH_COMMAND", "ssh -o BatchMode=yes");
    }
    let output = command.output().map_err(|err| err.to_string())?;
    if output.status.success() {
        return Ok(());
    }
    let stderr = String::from_utf8_lossy(&output.stderr);
    let message = stderr.lines().map(str::trim).rfind(|line| !line.is_empty()).unwrap_or("it failed");
    Err(message.to_string())
}

#[cfg(test)]
mod tests {
    use super::{Forge, ProjectSource, check_name, repo_name};
    use std::path::{Path, PathBuf};

    #[test]
    fn names_repositories() {
        assert_eq!(repo_name("https://github.com/zed-industries/zed.git").as_deref(), Some("zed"));
        assert_eq!(repo_name("https://github.com/zed-industries/zed/").as_deref(), Some("zed"));
        assert_eq!(repo_name("git@github.com:owner/app.git").as_deref(), Some("app"));
        assert_eq!(repo_name("git@host:app.git").as_deref(), Some("app"));
        assert_eq!(repo_name("owner/repo").as_deref(), Some("repo"));
        assert_eq!(repo_name(""), None);
        assert_eq!(repo_name("https://host/.."), None);
    }

    #[test]
    fn checks_names() {
        assert!(check_name("app").is_none());
        assert!(check_name("  ").is_some());
        assert!(check_name("a/b").is_some());
        assert!(check_name("..").is_some());
    }

    #[test]
    fn places_clones_in_the_base() {
        let base = Path::new("/home/ada/Work");
        let url = ProjectSource::Url { url: "https://example.com/x/app.git".into() };
        assert_eq!(url.destination(base), Some(PathBuf::from("/home/ada/Work/app")));
        let forge = ProjectSource::Forge { forge: Forge::GitHub, repo: "owner/tool".into() };
        assert_eq!(forge.destination(base), Some(PathBuf::from("/home/ada/Work/tool")));
        assert_eq!(ProjectSource::Folder { path: "~/x".into() }.destination(base), None);
    }
}