netsky 0.2.0

netsky CLI: the viable system launcher and subcommand dispatcher
Documentation
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

use clap::Subcommand;
use netsky_core::config::workspace_mirror_remotes;
use netsky_core::paths::resolve_netsky_dir;

const DEFAULT_ORIGIN_URL: &str = "https://github.com/lostmygithubaccount/netsky.git";
const ENV_WORKSPACE_ORIGIN_URL: &str = "NETSKY_WORKSPACE_ORIGIN_URL";

#[derive(Subcommand, Debug)]
#[command(subcommand_required = true, arg_required_else_help = true)]
pub enum WorkspaceCommand {
    /// Clone one workspace repo under workspaces/<task>/repo.
    Clone {
        #[arg(value_name = "TASK")]
        task: String,
    },
    /// List existing workspaces.
    #[command(visible_alias = "list")]
    Ls,
    /// Create a fresh branch inside a workspace.
    Branch {
        #[arg(value_name = "TASK")]
        task: String,
        #[arg(value_name = "BRANCH")]
        branch: String,
    },
}

pub fn run(cmd: WorkspaceCommand) -> netsky_core::Result<()> {
    match cmd {
        WorkspaceCommand::Clone { task } => {
            let repo_dir = clone_workspace(&task)?;
            println!("{}", repo_dir.display());
            Ok(())
        }
        WorkspaceCommand::Ls => list_workspaces(),
        WorkspaceCommand::Branch { task, branch } => create_branch(&task, &branch),
    }
}

pub(crate) fn clone_workspace(task: &str) -> netsky_core::Result<PathBuf> {
    validate_task_name(task)?;
    let repo_dir = repo_dir(task);
    if repo_dir.is_dir() {
        ensure_remote(&repo_dir, "origin", &canonical_origin_url())?;
        ensure_mirror_remotes(&repo_dir)?;
        return Ok(repo_dir);
    }
    if repo_dir.exists() {
        netsky_core::bail!(
            "workspace repo path exists but is not a directory: {}",
            repo_dir.display()
        );
    }
    fs::create_dir_all(repo_dir.parent().expect("repo dir has a parent"))?;
    git(
        None,
        &[
            "clone",
            &canonical_origin_url(),
            &repo_dir.display().to_string(),
        ],
    )?;
    ensure_mirror_remotes(&repo_dir)?;
    Ok(repo_dir)
}

fn list_workspaces() -> netsky_core::Result<()> {
    for task in workspace_names()? {
        println!("{task}");
    }
    Ok(())
}

fn create_branch(task: &str, branch: &str) -> netsky_core::Result<()> {
    validate_task_name(task)?;
    let repo_dir = repo_dir(task);
    if !repo_dir.is_dir() {
        netsky_core::bail!(
            "workspace repo missing: {}. Run `netsky workspace clone {task}` first.",
            repo_dir.display()
        );
    }
    git(Some(&repo_dir), &["checkout", "-b", branch])?;
    println!("{branch}");
    Ok(())
}

fn ensure_mirror_remotes(repo_dir: &Path) -> netsky_core::Result<()> {
    for spec in workspace_mirror_remotes() {
        let (name, url) = parse_remote_spec(&spec)?;
        if name == "origin" {
            netsky_core::bail!("workspace.mirror_remotes must not redefine `origin`");
        }
        ensure_remote(repo_dir, &name, &url)?;
    }
    Ok(())
}

fn ensure_remote(repo_dir: &Path, name: &str, url: &str) -> netsky_core::Result<()> {
    let current = git(Some(repo_dir), &["remote", "get-url", name]);
    match current {
        Ok(existing) if existing.trim() == url => Ok(()),
        Ok(_) => {
            git(Some(repo_dir), &["remote", "set-url", name, url])?;
            Ok(())
        }
        Err(_) => {
            git(Some(repo_dir), &["remote", "add", name, url])?;
            Ok(())
        }
    }
}

fn git(repo_dir: Option<&Path>, args: &[&str]) -> netsky_core::Result<String> {
    let mut command = Command::new("git");
    if let Some(repo_dir) = repo_dir {
        command.arg("-C").arg(repo_dir);
    }
    command.args(args);
    let output = command.output()?;
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
        let detail = if stderr.is_empty() {
            format!("git exited with status {}", output.status)
        } else {
            stderr
        };
        netsky_core::bail!("git {}: {detail}", args.join(" "));
    }
    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}

fn canonical_origin_url() -> String {
    std::env::var(ENV_WORKSPACE_ORIGIN_URL)
        .ok()
        .filter(|value| !value.trim().is_empty())
        .unwrap_or_else(|| DEFAULT_ORIGIN_URL.to_string())
}

fn workspace_root() -> PathBuf {
    resolve_netsky_dir().join("workspaces")
}

fn workspace_dir(task: &str) -> PathBuf {
    workspace_root().join(task)
}

fn repo_dir(task: &str) -> PathBuf {
    workspace_dir(task).join("repo")
}

fn workspace_names() -> netsky_core::Result<Vec<String>> {
    let root = workspace_root();
    let mut names = Vec::new();
    let entries = match fs::read_dir(&root) {
        Ok(entries) => entries,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(names),
        Err(err) => return Err(err.into()),
    };
    for entry in entries {
        let entry = entry?;
        if !entry.file_type()?.is_dir() {
            continue;
        }
        if entry.path().join("repo").is_dir() {
            names.push(entry.file_name().to_string_lossy().into_owned());
        }
    }
    names.sort();
    Ok(names)
}

fn validate_task_name(task: &str) -> netsky_core::Result<()> {
    if task.is_empty() {
        netsky_core::bail!("task must not be empty");
    }
    if task == "." || task == ".." || task.contains('/') {
        netsky_core::bail!("task must be a single path segment: {task}");
    }
    Ok(())
}

fn parse_remote_spec(spec: &str) -> netsky_core::Result<(String, String)> {
    let Some((name, url)) = spec.split_once('=') else {
        netsky_core::bail!("invalid workspace mirror remote `{spec}`. Use `name=url`.");
    };
    let name = name.trim();
    let url = url.trim();
    if name.is_empty() || url.is_empty() {
        netsky_core::bail!("invalid workspace mirror remote `{spec}`. Use `name=url`.");
    }
    Ok((name.to_string(), url.to_string()))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_remote_spec_requires_name_and_url() {
        let parsed = parse_remote_spec("iroh=ssh://mirror.example/netsky.git").unwrap();
        assert_eq!(parsed.0, "iroh");
        assert_eq!(parsed.1, "ssh://mirror.example/netsky.git");
        assert!(parse_remote_spec("missing-separator").is_err());
        assert!(parse_remote_spec("=ssh://mirror.example/netsky.git").is_err());
        assert!(parse_remote_spec("iroh=").is_err());
    }

    #[test]
    fn validate_task_name_rejects_path_traversal() {
        assert!(validate_task_name("ok-task").is_ok());
        assert!(validate_task_name("").is_err());
        assert!(validate_task_name("../bad").is_err());
        assert!(validate_task_name("bad/name").is_err());
    }

    #[test]
    fn canonical_origin_url_prefers_env_override() {
        unsafe {
            std::env::set_var(ENV_WORKSPACE_ORIGIN_URL, "/tmp/netsky-local.git");
        }
        assert_eq!(canonical_origin_url(), "/tmp/netsky-local.git");
        unsafe {
            std::env::remove_var(ENV_WORKSPACE_ORIGIN_URL);
        }
        assert_eq!(canonical_origin_url(), DEFAULT_ORIGIN_URL);
    }
}