switchdev 0.1.0

A fast CLI to instantly switch between development projects and run their startup commands
use serde_json::Value;
use std::fs;
use std::path::Path;

pub fn detect_command(path: &str) -> Option<String> {
    let base = Path::new(path);
    let package_json = base.join("package.json");

    if package_json.exists() {
        return Some(detect_node_command(&package_json));
    }

    if base.join("Cargo.toml").exists() {
        return Some("cargo run".to_string());
    }

    if base.join("main.py").exists() {
        return Some("python main.py".to_string());
    }

    if base.join("app.py").exists() {
        return Some("python app.py".to_string());
    }

    None
}

fn detect_node_command(package_json: &Path) -> String {
    match fs::read_to_string(package_json) {
        Ok(contents) => match serde_json::from_str::<Value>(&contents) {
            Ok(package) if has_dev_script(&package) => String::from("npm run dev"),
            Ok(_) | Err(_) => String::from("npm start"),
        },
        Err(_) => String::from("npm start"),
    }
}

fn has_dev_script(package: &Value) -> bool {
    package
        .get("scripts")
        .and_then(Value::as_object)
        .is_some_and(|scripts| scripts.contains_key("dev"))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;
    use std::time::{SystemTime, UNIX_EPOCH};

    fn test_dir(name: &str) -> PathBuf {
        let unique = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("system time should be after unix epoch")
            .as_nanos();

        std::env::temp_dir().join(format!("switchdev-detector-{name}-{unique}"))
    }

    #[test]
    fn detects_node_command_with_dev_script() {
        let dir = test_dir("node-dev");
        fs::create_dir_all(&dir).expect("test directory should be created");
        fs::write(
            dir.join("package.json"),
            r#"{
  "scripts": {
    "dev": "vite"
  }
}"#,
        )
        .expect("package.json should be written");

        assert_eq!(
            detect_command(dir.to_string_lossy().as_ref()),
            Some(String::from("npm run dev"))
        );

        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn falls_back_to_npm_start_without_dev_script() {
        let dir = test_dir("node-start");
        fs::create_dir_all(&dir).expect("test directory should be created");
        fs::write(
            dir.join("package.json"),
            r#"{
  "scripts": {
    "start": "node server.js"
  }
}"#,
        )
        .expect("package.json should be written");

        assert_eq!(
            detect_command(dir.to_string_lossy().as_ref()),
            Some(String::from("npm start"))
        );

        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn detects_rust_projects() {
        let dir = test_dir("rust");
        fs::create_dir_all(&dir).expect("test directory should be created");
        fs::write(dir.join("Cargo.toml"), "").expect("Cargo.toml should be written");

        assert_eq!(
            detect_command(dir.to_string_lossy().as_ref()),
            Some(String::from("cargo run"))
        );

        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn detects_python_main_file() {
        let dir = test_dir("python-main");
        fs::create_dir_all(&dir).expect("test directory should be created");
        fs::write(dir.join("main.py"), "print('hello')").expect("main.py should be written");

        assert_eq!(
            detect_command(dir.to_string_lossy().as_ref()),
            Some(String::from("python main.py"))
        );

        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn detects_python_app_file() {
        let dir = test_dir("python-app");
        fs::create_dir_all(&dir).expect("test directory should be created");
        fs::write(dir.join("app.py"), "print('hello')").expect("app.py should be written");

        assert_eq!(
            detect_command(dir.to_string_lossy().as_ref()),
            Some(String::from("python app.py"))
        );

        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn returns_none_when_no_project_files_match() {
        let dir = test_dir("no-match");
        fs::create_dir_all(&dir).expect("test directory should be created");

        assert_eq!(detect_command(dir.to_string_lossy().as_ref()), None);

        let _ = fs::remove_dir_all(&dir);
    }
}