diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..a31ae11
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,25 @@
+name: Tests
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Set up Python
+ uses: actions/setup-python@v4
+ with:
+ python-version: '3.x'
+
+ - name: Install dependencies
+ run: pip install -e .[test]
+
+ - name: Run pytest
+ run: pytest
\ No newline at end of file
diff --git a/README.md b/README.md
index fc39ed3..75d7543 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,11 @@
# Starlet Setup
A lightweight Python utility to quickly clone, configure, and build CMake projects — from single repos to full mono-repos.
-
+[](https://github.com/masonlet/starlet-setup/actions/workflows/tests.yml)
[](https://badge.fury.io/py/starlet-setup)
[](./LICENSE)
[]()
-
## Table of Contents
- [Features](#features)
- [Prerequisites](#prerequisites)
@@ -16,10 +15,15 @@ A lightweight Python utility to quickly clone, configure, and build CMake projec
- [Single Repository Mode](#single-repository-mode)
- [Mono-Repo Mode](#mono-repo-mode)
- [Profile Mode](#profile-mode-saved-configurations)
+- [Development](#development)
- [License](#license)
+
+
+
+
## Features
- **Single Repository Mode**:
- Clone a GitHub repository with simple `username/repo` syntax
@@ -42,8 +46,12 @@ A lightweight Python utility to quickly clone, configure, and build CMake projec
- Manage multiple development environments effortlessly
- Default profile includes all core Starlet modules
+
+
+
+
## Prerequisites
- Python 3.6+
- Git
@@ -94,8 +102,12 @@ Alternatively, you can run the script directly:
python -m starlet_setup username/repo
```
+
+
+
+
## Configuration
Starlet Setup supports persistent configuration through a JSON file, allowing you to save your preferred defaults (e.g., SSH mode, build directory, mono-repo repositories).
@@ -111,8 +123,12 @@ Starlet Setup checks for configuration files in this order:
- `./.starlet-setup.json` (current directory)
- `~/.starlet-setup.json` (home directory)
+
+
+
+
## Usage
### Single Repository Mode
@@ -270,7 +286,44 @@ starlet-setup username/repo --profile myprofile
starlet-setup username/repo --profile myprofile --ssh
```
+
+
+
+
+## Development
+
+### Running Tests
+
+#### 1. Clone the Repository
+```bash
+git clone https://github.com/masonlet/starlet-setup.git
+cd starlet-setup
+```
+
+#### 2. Install in Development Mode
+```bash
+pip install -e .
+```
+
+#### 3. Run Tests
+```bash
+# Run all tests
+pytest
+
+# Run specific test file
+pytest tests/test_config.py
+
+# Run tests with flags
+pytest -v
+```
+
+
+
+
+
+
+
## License
-MIT License — see [LICENSE](./LICENSE) for details.
+MIT License — see [LICENSE](./LICENSE) for details.
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 271b915..ed0dddc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "starlet-setup"
-version = "1.0.1"
+version = "1.0.2"
description = "Quick setup for CMake projects"
readme = "README.md"
requires-python = ">=3.6"
@@ -13,6 +13,11 @@ authors = [
{name = "Mason L'Etoile", email = "masonletoile@hotmail.com"}
]
+[project.optional-dependencies]
+test = [
+ "pytest>=7.0",
+]
+
[project.urls]
Homepage = "https://github.com/masonlet/starlet-setup"
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..3a87564
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,4 @@
+[pytest]
+minversion = 6.0
+addopts = -v
+testpaths = tests
\ No newline at end of file
diff --git a/src/starlet_setup/__main__.py b/src/starlet_setup/__main__.py
index 3529d3a..1b97f24 100644
--- a/src/starlet_setup/__main__.py
+++ b/src/starlet_setup/__main__.py
@@ -11,6 +11,7 @@
from .utils import check_prerequisites
from .commands import mono_repo_mode, single_repo_mode
+
def main() -> None:
"""Main entry point for Starlet Setup."""
args = parse_args()
diff --git a/src/starlet_setup/cli.py b/src/starlet_setup/cli.py
index e71efd3..88e2067 100644
--- a/src/starlet_setup/cli.py
+++ b/src/starlet_setup/cli.py
@@ -4,6 +4,7 @@
from argparse import Namespace
from .config import get_config_value, load_config
+
def parse_args() -> Namespace:
"""
Parse command-line arguments for Starlet Setup.
@@ -11,7 +12,7 @@ def parse_args() -> Namespace:
Returns:
Parsed arguments namespace
"""
- config = load_config()
+ config, config_path = load_config()
parser = argparse.ArgumentParser(
description="Starlet Setup - Quick setup script for CMake projects",
@@ -60,9 +61,9 @@ def parse_args() -> Namespace:
)
parser.add_argument(
'--cmake-arg',
- nargs='*',
- default=None,
- help='Additional CMake arguments (e.g., --cmake-arg=-D_BUILD_TESTS=ON)'
+ action='append',
+ dest='cmake_arg',
+ help='Additional CMake arguments (e.g., --cmake-arg=-D_BUILD_TESTS=ON). Can be used multiple times.'
)
# Configuration arguments
@@ -148,6 +149,7 @@ def parse_args() -> Namespace:
args = parser.parse_args()
args.config = config
+ args.config_path = config_path
if args.init_config or args.list_profiles or args.profile_add or args.profile_remove:
return args
diff --git a/src/starlet_setup/commands.py b/src/starlet_setup/commands.py
index fedff01..585c360 100644
--- a/src/starlet_setup/commands.py
+++ b/src/starlet_setup/commands.py
@@ -5,7 +5,6 @@
from argparse import Namespace
from pathlib import Path
from typing import Optional
-
from .repository import (
resolve_repo_url,
get_default_repos,
@@ -109,7 +108,6 @@ def single_repo_mode(args: Namespace) -> None:
print(f"Project finished in {repo_name}/{args.build_dir}")
-
def _create_mono_repo_cmakelists(mono_dir: Path, test_repo: str, repos: list[str]):
"""
Create a root CMakeLists.txt for the mono-repo.
diff --git a/src/starlet_setup/config.py b/src/starlet_setup/config.py
index 7171d17..36c6685 100644
--- a/src/starlet_setup/config.py
+++ b/src/starlet_setup/config.py
@@ -1,12 +1,11 @@
"""Configuration file management"""
-
import json
from pathlib import Path
from typing import Any
-def load_config() -> dict:
+def load_config() -> tuple[dict, Path | None]:
"""
Load configuration from file, falling back to defaults.
@@ -18,19 +17,37 @@ def load_config() -> dict:
Path.home() / '.starlet-setup.json'
]
+ invalid_count = 0
for config_path in config_locations:
if config_path.exists():
try:
with open(config_path) as f:
- return json.load(f)
+ return json.load(f), config_path
except json.JSONDecodeError as e:
print(f"Warning: Invalid JSON in {config_path}: {e}")
+ invalid_count += 1
+ continue
+ except PermissionError:
+ print(f"Error: No permission to read the file in {config_path}.")
+ invalid_count += 1
continue
+ except Exception as e:
+ print(f"An unexpected error occurred reading {config_path}: {e}")
+ invalid_count += 1
+ continue
+
- return {}
+ if invalid_count != 0:
+ print(f"Found {invalid_count} config file{'s' if invalid_count != 1 else ''} that had errors")
+ else:
+ print("Failed to find config file")
+ return {}, None
-def save_config(config) -> Path:
+def save_config(
+ config: dict,
+ config_path: Path | None = None
+) -> Path:
"""
Save configuration to a file.
@@ -40,13 +57,18 @@ def save_config(config) -> Path:
Returns:
Path where config was saved
"""
- config_path = Path('.starlet-setup.json')
- if not config_path.exists():
- config_path = Path.home() / '.starlet-setup.json'
-
- with open(config_path, 'w') as f:
- json.dump(config, f, indent=2)
-
+ if config_path is None:
+ config_path = Path('.starlet-setup.json')
+
+ try:
+ with open(config_path, 'w') as f:
+ json.dump(config, f, indent=2)
+ except PermissionError:
+ print(f"Error: No permission to write to {config_path}")
+ raise
+ except Exception as e:
+ print(f"An unexpected error occurred writing {config_path}: {e}")
+ raise
return config_path
@@ -56,16 +78,15 @@ def get_config_value(config: dict, key: str, default: Any) -> Any:
Args:
config: Configuration dictionary
- key: Dot-seperated key path (e.g, 'defaults.ssh')
+ key: Dot-separated key path (e.g, 'defaults.ssh')
default: Default value if key not found
"""
parts = key.split('.')
value = config
for part in parts:
- if isinstance(value, dict) and part in value:
- value = value[part]
- else:
+ if not isinstance(value, dict) or part not in value:
return default
+ value = value[part]
return value
@@ -101,11 +122,18 @@ def create_default_config() -> None:
print("Aborted.")
return
- with open(config_path, 'w') as f:
- json.dump(default_config, f, indent=2)
+ try:
+ with open(config_path, 'w') as f:
+ json.dump(default_config, f, indent=2)
+ except PermissionError:
+ print(f"Error: No permission to write to {config_path}")
+ return
+ except Exception as e:
+ print(f"An unexpected error occurred writing {config_path}: {e}")
+ return
print(f"Created config file: {config_path.absolute()}")
print("Edit this file to customize your defaults.")
print("\nConfig files are checked in this order:")
print(" 1. ./.starlet-setup.json (current directory)")
- print(" 2. ~/.starlet-setup.json (home directory)")
+ print(" 2. ~/.starlet-setup.json (home directory)")
\ No newline at end of file
diff --git a/src/starlet_setup/repository.py b/src/starlet_setup/repository.py
index 1f3230a..b8bdd79 100644
--- a/src/starlet_setup/repository.py
+++ b/src/starlet_setup/repository.py
@@ -4,6 +4,7 @@
from .config import get_config_value
from .utils import run_command
+
def resolve_repo_url(repo_input: str, use_ssh: bool=False) -> str:
"""
Convert repository input to full URL.
@@ -78,4 +79,4 @@ def clone_repository(
run_command(['git', 'clone', repo_url], cwd=target_dir, verbose=verbose)
except SystemExit:
print(f" Failed to clone {repo_path}")
- raise
+ raise
\ No newline at end of file
diff --git a/src/starlet_setup/utils.py b/src/starlet_setup/utils.py
index c012afa..f212fd1 100644
--- a/src/starlet_setup/utils.py
+++ b/src/starlet_setup/utils.py
@@ -33,8 +33,8 @@ def check_prerequisites(verbose: bool=False) -> None:
def run_command(
cmd: list[str],
- cwd: Optional[Union[str, Path]]=None,
- verbose: bool=False
+ cwd: Optional[Union[str, Path]] = None,
+ verbose: bool = False
) -> subprocess.CompletedProcess:
"""
Run a shell command with proper error handling
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_cli.py b/tests/test_cli.py
new file mode 100644
index 0000000..d999b9c
--- /dev/null
+++ b/tests/test_cli.py
@@ -0,0 +1,105 @@
+"""Tests for CLI module."""
+
+import pytest
+from unittest.mock import patch
+from starlet_setup.cli import parse_args
+
+
+class TestParseArgs:
+ def test_parses_basic_repository(self):
+ """Should parse repository argument."""
+ with patch('sys.argv', ['prog', 'user/repo']):
+ args = parse_args()
+ assert args.repo == 'user/repo'
+ assert args.ssh is False
+ assert args.verbose is False
+
+
+ def test_applies_config_defaults(self):
+ """Should use config values as default."""
+ config = {
+ "defaults": {"ssh": True, "verbose": True, "build_type": "Release"}
+ }
+ with patch('starlet_setup.cli.load_config', return_value=(config,None)), \
+ patch('sys.argv', ['prog', 'user/repo']):
+ args = parse_args()
+ assert args.ssh is True
+ assert args.verbose is True
+ assert args.build_type == "Release"
+
+
+ def test_command_line_overrides_config(self):
+ """Should allow CLI args to override config defaults."""
+ config = {"defaults": {"ssh": False}}
+ with patch('starlet_setup.cli.load_config', return_value=(config, None)), \
+ patch('sys.argv', ['prog', 'user/repo', '--ssh']):
+ args = parse_args()
+ assert args.ssh is True
+
+
+ def test_requires_repo_argument(self):
+ """Should error when repo not provided and no special flags."""
+ with patch('sys.argv', ['prog']), pytest.raises(SystemExit):
+ parse_args()
+
+
+ def test_allows_config_management_without_repo(self):
+ """Should allow profile commands without repo argument."""
+ with patch('sys.argv', ['prog', '--list-profiles']):
+ args = parse_args()
+ assert args.list_profiles is True
+
+
+ def test_enables_mono_repo_with_repos_flag(self):
+ """Should enable mono-repo mode when --repos specified."""
+ with patch('sys.argv', ['prog', 'user/repo', '--repos', 'lib1', 'lib2']):
+ args = parse_args()
+ assert args.mono_repo is True
+ assert args.repos == ['lib1', 'lib2']
+
+
+ def test_enables_mono_repo_with_profile_flag(self):
+ """Should enable mono-repo mode when --profile specified."""
+ with patch('sys.argv', ['prog', 'user/repo', '--profile']):
+ args = parse_args()
+ assert args.mono_repo is True
+ assert args.profile == 'default'
+
+
+ def test_profile_uses_default_when_no_name_given(self):
+ """Should use 'default' profile name when none specified."""
+ with patch('sys.argv', ['prog', 'user/repo', '--profile']):
+ args = parse_args()
+ assert args.profile == 'default'
+
+
+ def test_errors_on_both_repos_and_profile(self):
+ """Should error when both --repos and --profile specified."""
+ with patch('sys.argv', ['prog', 'user/repo', '--repos', 'lib1', '--profile']), \
+ pytest.raises(SystemExit):
+ parse_args()
+
+
+ def test_parses_build_options(self):
+ """Should parse build configuration options."""
+ with patch('sys.argv', ['prog', 'user/repo', '-b', 'Release', '-d', 'mybuild', '--no-build']):
+ args = parse_args()
+ assert args.build_type == 'Release'
+ assert args.build_dir == 'mybuild'
+ assert args.no_build is True
+
+
+ def test_parses_cmake_arguments(self):
+ """Should parse additional CMake arguments."""
+ with patch('sys.argv', ['prog', 'user/repo', '--cmake-arg=-DTEST=ON', '--cmake-arg=-DDEBUG=OFF']):
+ args = parse_args()
+ assert args.cmake_arg == ['-DTEST=ON', '-DDEBUG=OFF']
+
+
+ def test_attaches_config_to_args(self):
+ """Should attach loaded config to args namespace."""
+ config = {"defaults": {}}
+ with patch('starlet_setup.cli.load_config', return_value=(config, None)), \
+ patch('sys.argv', ['prog', 'user/repo']):
+ args = parse_args()
+ assert args.config == config
\ No newline at end of file
diff --git a/tests/test_commands.py b/tests/test_commands.py
new file mode 100644
index 0000000..fe3feb8
--- /dev/null
+++ b/tests/test_commands.py
@@ -0,0 +1,211 @@
+"""Tests for commands module."""
+
+from argparse import Namespace
+import pytest
+from unittest.mock import patch
+from starlet_setup.commands import (
+ single_repo_mode,
+ mono_repo_mode,
+ _create_mono_repo_cmakelists
+)
+
+
+class TestSingleRepoMode:
+ def test_clones_and_builds_new_repo(self, tmp_path, monkeypatch):
+ """Should clone and build repository when it doesn't exist."""
+ monkeypatch.chdir(tmp_path)
+ args = Namespace (
+ repo='user/repo',
+ ssh=False,
+ verbose=False,
+ build_dir='build',
+ build_type='Debug',
+ clean=False,
+ no_build=False,
+ cmake_arg=None,
+ config={}
+ )
+
+ def create_repo_on_clone(*args, **kwargs):
+ if 'clone' in str(args):
+ (tmp_path / 'repo').mkdir()
+
+ with patch('starlet_setup.commands.run_command', side_effect=create_repo_on_clone) as mock_run:
+ single_repo_mode(args)
+ assert mock_run.call_count >= 2
+ assert any ('git' in str(c) and 'clone' in str(c) for c in mock_run.call_args_list)
+
+
+ def test_updates_existing_repo_when_confirmed(self, tmp_path, monkeypatch):
+ """Should update existing repository when user confirms."""
+ monkeypatch.chdir(tmp_path)
+ repo_dir = tmp_path / "repo"
+ repo_dir.mkdir()
+
+ args = Namespace(
+ repo='user/repo',
+ ssh=False,
+ verbose=False,
+ build_dir='build',
+ build_type='Debug',
+ clean=False,
+ no_build=False,
+ cmake_arg=None,
+ config={}
+ )
+
+ with patch('starlet_setup.commands.run_command') as mock_run, \
+ patch('builtins.input', return_value='y'):
+ single_repo_mode(args)
+ assert any('pull' in str(c) for c in mock_run.call_args_list)
+
+
+ def test_skips_build_when_no_build_flag_set(self, tmp_path, monkeypatch):
+ """Should skip build step when no_build is True."""
+ monkeypatch.chdir(tmp_path)
+ args = Namespace(
+ repo='user/repo',
+ ssh=False,
+ verbose=False,
+ build_dir='build',
+ build_type='Debug',
+ clean=False,
+ no_build=True,
+ cmake_arg=None,
+ config={}
+ )
+
+ with patch('starlet_setup.commands.run_command') as mock_run, \
+ patch('builtins.input', return_value='n'):
+ (tmp_path / 'repo').mkdir()
+ single_repo_mode(args)
+ assert not any('--build' in str(c) for c in mock_run.call_args_list)
+
+
+ def test_uses_ssh_when_requested(self, tmp_path, monkeypatch):
+ """Should use SSH URL when ssh flag is True."""
+ monkeypatch.chdir(tmp_path)
+ args = Namespace(
+ repo='user/repo',
+ ssh=True,
+ verbose=False,
+ build_dir='build',
+ build_type='Debug',
+ clean=False,
+ no_build=True,
+ cmake_arg=None,
+ config={}
+ )
+
+ def create_repo_on_clone(*args, **kwargs):
+ if 'clone' in str(args):
+ (tmp_path / 'repo').mkdir()
+
+ with patch('starlet_setup.commands.run_command', side_effect=create_repo_on_clone) as mock_run:
+ single_repo_mode(args)
+ clone_call = [c for c in mock_run.call_args_list if 'clone' in str(c)][0]
+ assert 'git@github.com' in str(clone_call)
+
+
+class TestMonoRepoMode:
+ def test_clones_multiple_repos(self, tmp_path, monkeypatch):
+ """Should clone all specified repositories."""
+ monkeypatch.chdir(tmp_path)
+ args = Namespace (
+ repo='user/test-repo',
+ repos=['user/lib1', 'user/lib2'],
+ profile=None,
+ ssh=False,
+ verbose=False,
+ mono_dir='mono',
+ no_build=False,
+ cmake_arg=None,
+ config={}
+ )
+
+ with patch('starlet_setup.commands.clone_repository') as mock_clone, \
+ patch('starlet_setup.commands.run_command'):
+ mono_repo_mode(args)
+ assert mock_clone.call_count >= 3
+
+
+ def test_uses_profile_repos(self, tmp_path, monkeypatch):
+ """Should use repositories from specified profile."""
+ monkeypatch.chdir(tmp_path)
+ config = {
+ 'profiles': {
+ 'myprofile': ['user/lib1', 'user/lib2']
+ }
+ }
+ args = Namespace(
+ repo='user/test-repo',
+ repos=None,
+ profile='myprofile',
+ ssh=False,
+ verbose=False,
+ mono_dir='mono',
+ no_build=False,
+ cmake_arg=None,
+ config=config
+ )
+
+ with patch('starlet_setup.commands.clone_repository') as mock_clone, \
+ patch('starlet_setup.commands.run_command'):
+ mono_repo_mode(args)
+ assert mock_clone.call_count >= 3
+
+
+ def test_errors_on_missing_profile(self, tmp_path, monkeypatch):
+ """Should exit when specified profile doesn't exist."""
+ monkeypatch.chdir(tmp_path)
+ args = Namespace (
+ repo='user/test-repo',
+ repos=None,
+ profile='nonexistent',
+ ssh=False,
+ verbose=False,
+ mono_dir='mono',
+ no_build=False,
+ cmake_arg=None,
+ config={'profiles': {}}
+ )
+ with pytest.raises(SystemExit):
+ mono_repo_mode(args)
+
+
+ def test_creates_cmakelists(self, tmp_path, monkeypatch):
+ """Should create root CMakeLists.txt."""
+ monkeypatch.chdir(tmp_path)
+ args = Namespace(
+ repo='user/test-repo',
+ repos=['user/lib1'],
+ profile=None,
+ ssh=False,
+ verbose=False,
+ mono_dir='mono',
+ no_build=False,
+ cmake_arg=None,
+ config={}
+ )
+
+ with patch('starlet_setup.commands.clone_repository'), \
+ patch('starlet_setup.commands.run_command'):
+ mono_repo_mode(args)
+
+ cmake_file = tmp_path / 'mono' / 'CMakeLists.txt'
+ assert cmake_file.exists()
+
+
+class TestCreateMonoRepoCMakeLists:
+ def test_creates_cmakelists_with_repos(self, tmp_path):
+ """Should create CMakeLists.txt with all repositories."""
+ repos = ['user/lib1', 'user/lib2']
+ _create_mono_repo_cmakelists(tmp_path, 'test-repo', repos)
+
+ cmake_file = tmp_path / 'CMakeLists.txt'
+ assert cmake_file.exists()
+
+ content = cmake_file.read_text()
+ assert 'lib1' in content
+ assert 'lib2' in content
+ assert 'test-repo' in content
\ No newline at end of file
diff --git a/tests/test_config.py b/tests/test_config.py
new file mode 100644
index 0000000..a3b100f
--- /dev/null
+++ b/tests/test_config.py
@@ -0,0 +1,124 @@
+"""Tests for config module."""
+
+import json
+from pathlib import Path
+import pytest
+from unittest.mock import patch
+from starlet_setup.config import (
+ load_config,
+ save_config,
+ get_config_value,
+ create_default_config
+)
+
+
+@pytest.fixture
+def valid_config():
+ """Sample valid config."""
+ return {
+ "defaults": {"ssh": True, "verbose": False},
+ "profiles": {"default": ["repo1", "repo2"]}
+ }
+
+
+class TestLoadConfig:
+ def test_loads_from_current_directory(self, tmp_path, valid_config, monkeypatch):
+ """Should prioritize current directory config."""
+ monkeypatch.chdir(tmp_path)
+ config_path = tmp_path / ".starlet-setup.json"
+ with open(config_path, 'w') as f:
+ json.dump(valid_config, f)
+
+ config, path = load_config()
+ assert config == valid_config
+ assert path.resolve() == config_path.resolve()
+
+
+ def test_returns_empty_dict_when_no_config_exists(self, tmp_path, monkeypatch, capsys):
+ """Should return empty dict when no config found."""
+ monkeypatch.chdir(tmp_path)
+ config, path = load_config()
+ assert config == {}
+ assert path is None
+ assert "Failed to find config file" in capsys.readouterr().out
+
+
+ def test_handles_invalid_json(self, tmp_path, monkeypatch, capsys):
+ """Should handle malformed JSON."""
+ monkeypatch.chdir(tmp_path)
+ config_path = tmp_path / ".starlet-setup.json"
+ with open(config_path, 'w') as f:
+ f.write("{invalid json")
+
+ config, path = load_config()
+ assert config == {}
+ captured = capsys.readouterr()
+ assert "Invalid JSON" in captured.out
+
+
+class TestSaveConfig:
+ def test_saves_config_successfully(self, tmp_path, valid_config):
+ """Should write config to specified path."""
+ config_path = tmp_path / "test-config.json"
+ result_path = save_config(valid_config, config_path)
+
+ assert result_path == config_path
+ with open(config_path) as f:
+ saved_config = json.load(f)
+ assert saved_config == valid_config
+
+
+ def test_uses_default_path_when_none_provided(self, tmp_path, valid_config, monkeypatch):
+ """Should use default to .starlet-setup.json in current directory."""
+ monkeypatch.chdir(tmp_path)
+ result_path = save_config(valid_config)
+
+ assert result_path == Path('.starlet-setup.json')
+ assert (tmp_path / ".starlet-setup.json").exists()
+
+
+class TestGetConfigValue:
+ def test_retrieves_nested_value(self, valid_config):
+ """Should navigate dot-separated keys."""
+ assert get_config_value(valid_config, "defaults.ssh", False) is True
+ assert get_config_value(valid_config, "defaults.verbose", True) is False
+
+
+ def test_returns_default_when_key_missing(self, valid_config):
+ """Should return default for non-existent keys."""
+ assert get_config_value(valid_config, "fake.key", "default") == "default"
+ assert get_config_value(valid_config, "defaults.numbermissing", 42) == 42
+
+
+ def test_handles_non_dict_intermediate_values(self):
+ """Should return default when path encounters non-dict."""
+ config = {"key": "string_value"}
+ assert get_config_value(config, "key.nested", "default") == "default"
+
+
+class TestCreateDefaultConfig:
+ def test_creates_config_file(self, tmp_path, monkeypatch):
+ """Should create default config file."""
+ monkeypatch.chdir(tmp_path)
+ with patch('builtins.input', return_value='y'):
+ create_default_config()
+
+ config_path = tmp_path / ".starlet-setup.json"
+ assert config_path.exists()
+ with open(config_path) as f:
+ config = json.load(f)
+ assert "defaults" in config
+ assert "profiles" in config
+
+
+ def test_prompts_before_overwriting(self, tmp_path, monkeypatch, capsys):
+ """Should ask permission before overwriting existing config."""
+ monkeypatch.chdir(tmp_path)
+ config_path = tmp_path / ".starlet-setup.json"
+ config_path.write_text("{}")
+
+ with patch('builtins.input', return_value='n'):
+ create_default_config()
+
+ assert "Aborted" in capsys.readouterr().out
+ assert config_path.read_text() == "{}"
\ No newline at end of file
diff --git a/tests/test_profiles.py b/tests/test_profiles.py
new file mode 100644
index 0000000..bb9ac00
--- /dev/null
+++ b/tests/test_profiles.py
@@ -0,0 +1,131 @@
+"""Tests for profiles module."""
+
+from pathlib import Path
+import pytest
+from unittest.mock import patch
+from starlet_setup.profiles import (
+ add_profile,
+ remove_profile,
+ list_profiles
+)
+
+
+class TestAddProfile:
+ def test_adds_new_profile(self, capsys):
+ """Should add new profile to config."""
+ config = {'profiles': {}}
+
+ with patch('starlet_setup.profiles.save_config', return_value=Path('config.json')):
+ add_profile(config, ['myprofile', 'user/repo1', 'user/repo2'])
+
+ assert 'myprofile' in config['profiles']
+ assert config['profiles']['myprofile'] == ['user/repo1', 'user/repo2']
+ assert "added successfully" in capsys.readouterr().out
+
+
+ def test_creates_profiles_key_if_missing(self):
+ """Should create profiles dict if not present."""
+ config = {}
+
+ with patch('starlet_setup.profiles.save_config', return_value=Path('config.json')):
+ add_profile(config, ['myprofile', 'user/repo1'])
+
+ assert 'profiles' in config
+ assert 'myprofile' in config['profiles']
+
+
+ def test_overwrites_existing_profile_when_confirmed(self):
+ """Should overwrite existing profile when user confirms."""
+ config = {'profiles': {'myprofile': ['old/repo']}}
+
+ with patch('starlet_setup.profiles.save_config', return_value=Path('config.json')), \
+ patch('builtins.input', return_value='y'):
+ add_profile(config, ['myprofile', 'new/repo1', 'new/repo2'])
+
+ assert config['profiles']['myprofile'] == ['new/repo1', 'new/repo2']
+
+
+ def test_aborts_overwrite_when_not_confirmed(self, capsys):
+ """Should not overwrite when user declines."""
+ config = {'profiles': {'myprofile': ['old/repo']}}
+
+ with patch('starlet_setup.profiles.save_config'), \
+ patch('builtins.input', return_value='n'):
+ add_profile(config, ['myprofile', 'new/repo1'])
+
+ assert config['profiles']['myprofile'] == ['old/repo']
+ assert "Aborted" in capsys.readouterr().out
+
+
+ def test_errors_on_insufficient_arguments(self):
+ """Should exit when not enough arguments provided."""
+ config = {}
+
+ with pytest.raises(SystemExit):
+ add_profile(config, ['myprofile'])
+
+
+class TestRemoveProfile:
+ def test_removes_existing_profile(self):
+ """Should remove profile when confirmed."""
+ config = {'profiles': {'myprofile': ['user/repo1', 'user/repo2']}}
+
+ with patch('starlet_setup.profiles.save_config', return_value=Path('config.json')), \
+ patch('builtins.input', return_value='y'):
+ remove_profile(config, 'myprofile')
+
+ assert 'myprofile' not in config['profiles']
+
+
+ def test_aborts_removal_when_not_confirmed(self, capsys):
+ """Should not remove profile when declined."""
+ config = {'profiles': {'myprofile': ['user/repo1', 'user/repo2']}}
+
+ with patch('builtins.input', return_value='n'):
+ remove_profile(config, 'myprofile')
+
+ assert 'myprofile' in config['profiles']
+ assert "Aborted" in capsys.readouterr().out
+
+
+ def test_handles_nonexistent_profile(self, capsys):
+ """Should warn when profile doesn't exist."""
+ config = {'profiles': {}}
+
+ remove_profile(config, 'nonexistent')
+ assert "not found" in capsys.readouterr().out
+
+
+class TestListProfiles:
+ def test_lists_all_profiles(self, capsys):
+ """Should display all configured profiles."""
+ config = {
+ 'profiles': {
+ 'profile1': ['user/repo1', 'user/repo2'],
+ 'profile2': ['user/repo3']
+ }
+ }
+
+ list_profiles(config)
+
+ output = capsys.readouterr().out
+ assert 'profile1' in output
+ assert 'profile2' in output
+ assert 'user/repo1' in output
+ assert 'user/repo3' in output
+
+
+ def test_handles_empty_profiles(self, capsys):
+ """Should show message when no profiles configured."""
+ config = {'profiles': {}}
+
+ list_profiles(config)
+
+ assert "No profiles configured" in capsys.readouterr().out
+
+
+ def test_handles_missing_profiles_key(self, capsys):
+ """Should handle config without profiles key."""
+ config = {}
+ list_profiles(config)
+ assert "No profiles configured" in capsys.readouterr().out
\ No newline at end of file
diff --git a/tests/test_repository.py b/tests/test_repository.py
new file mode 100644
index 0000000..e7b9a04
--- /dev/null
+++ b/tests/test_repository.py
@@ -0,0 +1,94 @@
+"""Tests for repository module."""
+
+import pytest
+from unittest.mock import patch
+from starlet_setup.repository import (
+ resolve_repo_url,
+ get_default_repos,
+ clone_repository
+)
+
+class TestResolveRepoUrl:
+ def test_returns_full_url_unchanged(self):
+ """Should return full URLs as-is."""
+ https_url = "https://github.com/user/repo.git"
+ assert resolve_repo_url(https_url) == https_url
+
+ git_url = "git@github.com:user/repo.git"
+ assert resolve_repo_url(git_url) == git_url
+
+
+ def test_converts_shorthand_to_https(self):
+ """Should convert username/repo to HTTPS."""
+ result = resolve_repo_url("masonlet/starlet-math")
+ assert result == "https://github.com/masonlet/starlet-math.git"
+
+
+ def test_converts_shorthand_to_ssh(self):
+ """Should convert username/repo to SSH when requested."""
+ result = resolve_repo_url("masonlet/starlet-math", use_ssh=True)
+ assert result == "git@github.com:masonlet/starlet-math.git"
+
+
+class TestGetDefaultRepos:
+ def test_returns_repos_from_config(self):
+ """Should use profiles.default from config when available."""
+ config = {
+ "profiles": {
+ "default": ["user/repo1", "user/repo2"]
+ }
+ }
+ result = get_default_repos(config)
+ assert result == ["user/repo1", "user/repo2"]
+
+
+ def test_returns_hardcoded_defaults_when_config_empty(self):
+ """Should fall back to built-in Starlet repos."""
+ result = get_default_repos({})
+ assert "masonlet/starlet-math" in result
+ assert "masonlet/starlet-engine" in result
+ assert len(result) == 7
+
+
+class TestCloneRepository:
+ def test_clones_repository_successfully(self, tmp_path, capsys):
+ """Should clone repo to target directory."""
+ with patch('starlet_setup.repository.run_command') as mock_run:
+ clone_repository("user/repo", tmp_path, use_ssh=False, verbose=False)
+
+ mock_run.assert_called_once()
+ command_args, command_kwargs = mock_run.call_args
+ assert command_args[0][:2] == ['git', 'clone']
+ assert 'https://github.com/user/repo.git' in command_args[0]
+ assert command_kwargs['cwd'] == tmp_path
+
+ assert "Cloning repo" in capsys.readouterr().out
+
+
+ def test_skips_existing_directory(self, tmp_path, capsys):
+ """Should skip cloning if directory exists."""
+ repo_dir = tmp_path / "repo"
+ repo_dir.mkdir()
+
+ with patch('starlet_setup.repository.run_command') as mock_run:
+ clone_repository("user/repo", tmp_path, use_ssh=False, verbose=False)
+ mock_run.assert_not_called()
+
+ assert "already exists" in capsys.readouterr().out
+
+
+ def test_uses_ssh_when_requested(self, tmp_path):
+ """Should use SSH when use_ssh is True."""
+ with patch('starlet_setup.repository.run_command') as mock_run:
+ clone_repository("user/repo", tmp_path, use_ssh=True, verbose=False)
+
+ command_args, _ = mock_run.call_args
+ assert 'git@github.com:user/repo.git' in command_args[0]
+
+
+ def test_raises_on_clone_failure(self, tmp_path):
+ """Should propagate SystemExit when git clone fails."""
+ with patch('starlet_setup.repository.run_command') as mock_run, \
+ pytest.raises(SystemExit):
+ mock_run.side_effect = SystemExit(1)
+ clone_repository("user/repo", tmp_path, use_ssh=False, verbose=False)
\ No newline at end of file
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 0000000..eac77cd
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,87 @@
+"""Tests for utils module."""
+
+import subprocess
+import pytest
+from unittest.mock import patch, Mock
+from starlet_setup.utils import (
+ check_prerequisites,
+ run_command
+)
+
+
+class TestCheckPrerequisites:
+ def test_passes_when_tools_installed(self):
+ """Should complete without error when git and cmake exist."""
+ with patch('shutil.which', return_value='/usr/bin/git'):
+ check_prerequisites()
+
+
+ def test_exits_when_tools_missing(self, capsys):
+ """Should exit with error message when tools missing."""
+ with patch('shutil.which', return_value=None), pytest.raises(SystemExit) as exc_info:
+ check_prerequisites()
+
+ assert exc_info.value.code == 1
+ assert "Missing required tools: git, cmake" in capsys.readouterr().out
+
+
+ def test_shows_found_tools_in_verbose_mode(self, capsys):
+ """Should print found tools when verbose enabled."""
+ with patch('shutil.which', return_value='/usr/bin/tool'):
+ check_prerequisites(verbose=True)
+
+ output = capsys.readouterr().out
+ assert "Found git" in output
+ assert "Found cmake" in output
+
+
+class TestRunCommand:
+ def test_executes_command_successfully(self):
+ """Should run command and return CompletedProcess."""
+ with patch('subprocess.run') as mock_run:
+ mock_run.return_value = Mock(stdout="output", returncode=0)
+ result = run_command(['echo', 'test'])
+ mock_run.assert_called_once()
+ assert result.returncode == 0
+
+
+ def test_exits_on_command_failure(self, capsys):
+ """Should exit when command returns non-zero."""
+ with patch('subprocess.run') as mock_run, pytest.raises(SystemExit) as exc_info:
+ mock_run.side_effect = subprocess.CalledProcessError(
+ 1, ['false'], stderr="error"
+ )
+ run_command(['false'])
+
+ assert exc_info.value.code == 1
+ assert "Error running command" in capsys.readouterr().out
+
+
+ def test_exits_on_command_not_found(self, capsys):
+ """Should exit when command does not exist."""
+ with patch('subprocess.run') as mock_run, pytest.raises(SystemExit) as exc_info:
+ mock_run.side_effect = FileNotFoundError()
+ run_command(['nonexistent'])
+
+ assert exc_info.value.code == 1
+ assert "Command not found: nonexistent" in capsys.readouterr().out
+
+
+ def test_uses_working_directory(self, tmp_path):
+ """Should execute command in specific directory."""
+ with patch('subprocess.run') as mock_run:
+ mock_run.return_value = Mock(returncode=0)
+ run_command(['ls'], cwd=tmp_path)
+
+ assert mock_run.call_args[1]['cwd'] == tmp_path
+
+
+ def test_prints_details_in_verbose_mode(self, capsys):
+ """Should show command and directory in verbose mode."""
+ with patch('subprocess.run') as mock_run:
+ mock_run.return_value = Mock(stdout="output", returncode=0)
+ run_command(['echo', 'test'], cwd='/tmp', verbose=True)
+
+ output = capsys.readouterr().out
+ assert "Running: echo test" in output
+ assert "in directory: /tmp" in output
\ No newline at end of file