diff --git a/concore_cli/cli.py b/concore_cli/cli.py index b5336aa..90cf112 100644 --- a/concore_cli/cli.py +++ b/concore_cli/cli.py @@ -11,6 +11,7 @@ from .commands.inspect import inspect_workflow from .commands.watch import watch_study from .commands.doctor import doctor_check +from .commands.setup import setup_concore from . import __version__ console = Console() @@ -151,5 +152,19 @@ def doctor(): sys.exit(1) +@cli.command() +@click.option("--dry-run", is_flag=True, help="Preview detected config without writing") +@click.option("--force", is_flag=True, help="Overwrite existing config files") +def setup(dry_run, force): + """Auto-detect tools and write concore config files""" + try: + ok = setup_concore(console, dry_run=dry_run, force=force) + if not ok: + sys.exit(1) + except Exception as e: + console.print(f"[red]Error:[/red] {str(e)}") + sys.exit(1) + + if __name__ == "__main__": cli() diff --git a/concore_cli/commands/setup.py b/concore_cli/commands/setup.py new file mode 100644 index 0000000..829f24d --- /dev/null +++ b/concore_cli/commands/setup.py @@ -0,0 +1,103 @@ +from pathlib import Path + +from .doctor import ( + TOOL_DEFINITIONS, + _detect_tool, + _get_platform_key, + _resolve_concore_path, +) + + +def _pick_config_key(config_keys, plat_key): + if plat_key == "windows": + for key in config_keys: + if key.endswith("WIN"): + return key + else: + for key in config_keys: + if key.endswith("EXE"): + return key + return config_keys[0] if config_keys else None + + +def _detect_tool_overrides(plat_key): + found = [] + for tool_def in TOOL_DEFINITIONS.values(): + config_keys = tool_def.get("config_keys", []) + if not config_keys: + continue + candidates = tool_def["names"].get(plat_key, []) + path, _ = _detect_tool(candidates) + if not path: + continue + config_key = _pick_config_key(config_keys, plat_key) + if config_key: + found.append((config_key, path)) + return found + + +def _write_text(path, content, dry_run, force, console): + if path.exists() and not force: + console.print( + f"[yellow]![/yellow] Skipping {path.name} (already exists; use --force)" + ) + return True + if dry_run: + preview = content if content else "" + console.print(f"[dim]-[/dim] Would write {path.name}:\n{preview}") + return True + path.write_text(content) + console.print(f"[green]+[/green] Wrote {path.name}") + return True + + +def setup_concore(console, dry_run=False, force=False): + plat_key = _get_platform_key() + concore_path = _resolve_concore_path() + + console.print(f"[cyan]CONCOREPATH:[/cyan] {concore_path}") + + tool_overrides = _detect_tool_overrides(plat_key) + docker_candidates = TOOL_DEFINITIONS["Docker"]["names"].get(plat_key, []) + _, docker_command = _detect_tool(docker_candidates) + octave_candidates = TOOL_DEFINITIONS["Octave"]["names"].get(plat_key, []) + octave_path, _ = _detect_tool(octave_candidates) + octave_found = bool(octave_path) + + wrote_any = False + + tools_file = Path(concore_path) / "concore.tools" + if tool_overrides: + tools_content = "\n".join(f"{k}={v}" for k, v in tool_overrides) + "\n" + wrote_any = ( + _write_text(tools_file, tools_content, dry_run, force, console) or wrote_any + ) + else: + console.print("[yellow]![/yellow] No tool paths detected for concore.tools") + + sudo_file = Path(concore_path) / "concore.sudo" + if docker_command: + sudo_content = f"{docker_command}\n" + wrote_any = ( + _write_text(sudo_file, sudo_content, dry_run, force, console) or wrote_any + ) + else: + console.print( + "[yellow]![/yellow] Docker/Podman not detected; not writing concore.sudo" + ) + + octave_file = Path(concore_path) / "concore.octave" + if octave_found: + wrote_any = _write_text(octave_file, "", dry_run, force, console) or wrote_any + else: + console.print("[dim]-[/dim] Octave not detected; not writing concore.octave") + + if not wrote_any: + console.print("[yellow]No files written.[/yellow]") + return False + + if dry_run: + console.print("[green]Dry run complete.[/green]") + else: + console.print("[green]Setup complete.[/green]") + return True diff --git a/tests/test_setup.py b/tests/test_setup.py new file mode 100644 index 0000000..42a05fe --- /dev/null +++ b/tests/test_setup.py @@ -0,0 +1,139 @@ +import tempfile +import shutil +import unittest +from pathlib import Path +from unittest.mock import patch + +from click.testing import CliRunner +from concore_cli.cli import cli + + +class TestSetupCommand(unittest.TestCase): + def setUp(self): + self.runner = CliRunner() + self.temp_dir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.temp_dir) + + @patch("concore_cli.commands.setup._resolve_concore_path") + @patch("concore_cli.commands.setup._detect_tool") + @patch("concore_cli.commands.setup._get_platform_key") + def test_setup_dry_run_does_not_write(self, mock_plat, mock_detect, mock_path): + mock_plat.return_value = "posix" + mock_path.return_value = Path(self.temp_dir) + + def detect_side_effect(names): + if "g++" in names: + return "/usr/bin/g++", "g++" + if "python3" in names: + return "/usr/bin/python3", "python3" + if "iverilog" in names: + return "/usr/bin/iverilog", "iverilog" + if "octave" in names: + return "/usr/bin/octave", "octave" + if "docker" in names: + return "/usr/bin/docker", "docker" + return None, None + + mock_detect.side_effect = detect_side_effect + + result = self.runner.invoke(cli, ["setup", "--dry-run"]) + self.assertEqual(result.exit_code, 0) + + self.assertFalse((Path(self.temp_dir) / "concore.tools").exists()) + self.assertFalse((Path(self.temp_dir) / "concore.sudo").exists()) + self.assertFalse((Path(self.temp_dir) / "concore.octave").exists()) + + @patch("concore_cli.commands.setup._resolve_concore_path") + @patch("concore_cli.commands.setup._detect_tool") + @patch("concore_cli.commands.setup._get_platform_key") + def test_setup_writes_files(self, mock_plat, mock_detect, mock_path): + mock_plat.return_value = "posix" + mock_path.return_value = Path(self.temp_dir) + + def detect_side_effect(names): + if "g++" in names: + return "/usr/bin/g++", "g++" + if "python3" in names: + return "/usr/bin/python3", "python3" + if "iverilog" in names: + return "/usr/bin/iverilog", "iverilog" + if "octave" in names: + return "/usr/bin/octave", "octave" + if "docker" in names: + return "/usr/bin/docker", "docker" + return None, None + + mock_detect.side_effect = detect_side_effect + + result = self.runner.invoke(cli, ["setup"]) + self.assertEqual(result.exit_code, 0) + + tools_file = Path(self.temp_dir) / "concore.tools" + sudo_file = Path(self.temp_dir) / "concore.sudo" + octave_file = Path(self.temp_dir) / "concore.octave" + + self.assertTrue(tools_file.exists()) + self.assertTrue(sudo_file.exists()) + self.assertTrue(octave_file.exists()) + + tools_content = tools_file.read_text() + self.assertIn("CPPEXE=/usr/bin/g++", tools_content) + self.assertIn("PYTHONEXE=/usr/bin/python3", tools_content) + self.assertIn("VEXE=/usr/bin/iverilog", tools_content) + self.assertIn("OCTAVEEXE=/usr/bin/octave", tools_content) + self.assertEqual(sudo_file.read_text().strip(), "docker") + + @patch("concore_cli.commands.setup._resolve_concore_path") + @patch("concore_cli.commands.setup._detect_tool") + @patch("concore_cli.commands.setup._get_platform_key") + def test_setup_no_force_keeps_existing(self, mock_plat, mock_detect, mock_path): + mock_plat.return_value = "posix" + mock_path.return_value = Path(self.temp_dir) + + tools_file = Path(self.temp_dir) / "concore.tools" + tools_file.write_text("CPPEXE=/old/path\n") + + def detect_side_effect(names): + if "g++" in names: + return "/usr/bin/g++", "g++" + if "python3" in names: + return "/usr/bin/python3", "python3" + return None, None + + mock_detect.side_effect = detect_side_effect + + result = self.runner.invoke(cli, ["setup"]) + self.assertEqual(result.exit_code, 0) + self.assertEqual(tools_file.read_text(), "CPPEXE=/old/path\n") + + @patch("concore_cli.commands.setup._resolve_concore_path") + @patch("concore_cli.commands.setup._detect_tool") + @patch("concore_cli.commands.setup._get_platform_key") + def test_setup_force_overwrites_existing(self, mock_plat, mock_detect, mock_path): + mock_plat.return_value = "posix" + mock_path.return_value = Path(self.temp_dir) + + tools_file = Path(self.temp_dir) / "concore.tools" + tools_file.write_text("CPPEXE=/old/path\n") + + def detect_side_effect(names): + if "g++" in names: + return "/usr/bin/g++", "g++" + if "python3" in names: + return "/usr/bin/python3", "python3" + return None, None + + mock_detect.side_effect = detect_side_effect + + result = self.runner.invoke(cli, ["setup", "--force"]) + self.assertEqual(result.exit_code, 0) + + content = tools_file.read_text() + self.assertIn("CPPEXE=/usr/bin/g++", content) + self.assertIn("PYTHONEXE=/usr/bin/python3", content) + + +if __name__ == "__main__": + unittest.main()