diff --git a/src/archml/cli/main.py b/src/archml/cli/main.py index 5530396..7af219d 100644 --- a/src/archml/cli/main.py +++ b/src/archml/cli/main.py @@ -167,7 +167,7 @@ def _cmd_init(args: argparse.Namespace) -> int: def _cmd_check(args: argparse.Namespace) -> int: """Handle the check subcommand.""" - from archml.workspace.config import GitPathImport, LocalPathImport + from archml.workspace.config import GitPathImport, LocalPathImport, find_workspace_root directory = Path(args.directory).resolve() @@ -178,11 +178,16 @@ def _cmd_check(args: argparse.Namespace) -> int: workspace_yaml = directory / ".archml-workspace.yaml" if not workspace_yaml.exists(): - print( - f"Error: no ArchML workspace found at '{directory}'. Run 'archml init' to initialize a workspace.", - file=sys.stderr, - ) - return 1 + root = find_workspace_root(directory) + if root is None: + print( + f"Error: no ArchML workspace found at '{directory}' or any parent directory." + "Run 'archml init' to initialize a workspace.", + file=sys.stderr, + ) + return 1 + directory = root + workspace_yaml = directory / ".archml-workspace.yaml" # The empty-string key represents the workspace root for non-mnemonic imports. source_import_map: dict[str, Path] = {"": directory} @@ -244,6 +249,8 @@ def _cmd_check(args: argparse.Namespace) -> int: def _cmd_serve(args: argparse.Namespace) -> int: """Handle the serve subcommand.""" + from archml.workspace.config import find_workspace_root + directory = Path(args.directory).resolve() if not directory.exists(): @@ -253,11 +260,16 @@ def _cmd_serve(args: argparse.Namespace) -> int: workspace_yaml = directory / ".archml-workspace.yaml" if not workspace_yaml.exists(): - print( - f"Error: no ArchML workspace found at '{directory}'. Run 'archml init' to initialize a workspace.", - file=sys.stderr, - ) - return 1 + root = find_workspace_root(directory) + if root is None: + print( + f"Error: no ArchML workspace found at '{directory}' or any parent directory." + "Run 'archml init' to initialize a workspace.", + file=sys.stderr, + ) + return 1 + directory = root + workspace_yaml = directory / ".archml-workspace.yaml" from archml.webui.app import create_app @@ -269,7 +281,7 @@ def _cmd_serve(args: argparse.Namespace) -> int: def _cmd_sync_remote(args: argparse.Namespace) -> int: """Handle the sync-remote subcommand.""" - from archml.workspace.config import GitPathImport + from archml.workspace.config import GitPathImport, find_workspace_root from archml.workspace.git_ops import GitError, clone_at_commit, get_current_commit from archml.workspace.lockfile import LOCKFILE_NAME, LockfileError, load_lockfile @@ -279,18 +291,18 @@ def _cmd_sync_remote(args: argparse.Namespace) -> int: print(f"Error: directory '{directory}' does not exist.", file=sys.stderr) return 1 - workspace_file = directory / ".archml-workspace" - if not workspace_file.exists(): - print( - f"Error: no ArchML workspace found at '{directory}'. Run 'archml init' to initialize a workspace.", - file=sys.stderr, - ) - return 1 - workspace_yaml = directory / ".archml-workspace.yaml" if not workspace_yaml.exists(): - print("No workspace configuration found. Nothing to sync.") - return 0 + root = find_workspace_root(directory) + if root is None: + print( + f"Error: no ArchML workspace found at '{directory}' or any parent directory." + "Run 'archml init' to initialize a workspace.", + file=sys.stderr, + ) + return 1 + directory = root + workspace_yaml = directory / ".archml-workspace.yaml" try: config = load_workspace_config(workspace_yaml) @@ -357,7 +369,7 @@ def _cmd_sync_remote(args: argparse.Namespace) -> int: def _cmd_update_remote(args: argparse.Namespace) -> int: """Handle the update-remote subcommand.""" - from archml.workspace.config import GitPathImport + from archml.workspace.config import GitPathImport, find_workspace_root from archml.workspace.git_ops import GitError, is_commit_hash, resolve_commit from archml.workspace.lockfile import ( LOCKFILE_NAME, @@ -374,18 +386,18 @@ def _cmd_update_remote(args: argparse.Namespace) -> int: print(f"Error: directory '{directory}' does not exist.", file=sys.stderr) return 1 - workspace_file = directory / ".archml-workspace" - if not workspace_file.exists(): - print( - f"Error: no ArchML workspace found at '{directory}'. Run 'archml init' to initialize a workspace.", - file=sys.stderr, - ) - return 1 - workspace_yaml = directory / ".archml-workspace.yaml" if not workspace_yaml.exists(): - print("No workspace configuration found. Nothing to update.") - return 0 + root = find_workspace_root(directory) + if root is None: + print( + f"Error: no ArchML workspace found at '{directory}' or any parent directory." + "Run 'archml init' to initialize a workspace.", + file=sys.stderr, + ) + return 1 + directory = root + workspace_yaml = directory / ".archml-workspace.yaml" try: config = load_workspace_config(workspace_yaml) diff --git a/src/archml/workspace/config.py b/src/archml/workspace/config.py index 2daf245..93ac746 100644 --- a/src/archml/workspace/config.py +++ b/src/archml/workspace/config.py @@ -49,6 +49,31 @@ class WorkspaceConfig(BaseModel): source_imports: list[LocalPathImport | GitPathImport] = Field(alias="source-imports", default_factory=list) +WORKSPACE_CONFIG_FILENAME = ".archml-workspace.yaml" + + +def find_workspace_root(start_dir: Path) -> Path | None: + """Walk up the directory tree to find the ArchML workspace root. + + Searches for a .archml-workspace.yaml file starting from start_dir and + ascending through parent directories until the filesystem root is reached. + + Args: + start_dir: Directory to start the search from. + + Returns: + The directory containing .archml-workspace.yaml, or None if not found. + """ + current = start_dir.resolve() + while True: + if (current / WORKSPACE_CONFIG_FILENAME).exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + def load_workspace_config(path: Path) -> WorkspaceConfig: """Load and validate a workspace configuration file. diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py index 96aaa54..8f802be 100644 --- a/tests/cli/test_main.py +++ b/tests/cli/test_main.py @@ -201,13 +201,29 @@ def test_check_invalid_workspace_yaml( def test_check_fails_if_no_workspace(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """check exits with error code 1 when no workspace file is found.""" + """check exits with error code 1 when no workspace file is found anywhere in the tree.""" monkeypatch.setattr(sys, "argv", ["archml", "check", str(tmp_path)]) with pytest.raises(SystemExit) as exc_info: main() assert exc_info.value.code == 1 +def test_check_autodetects_workspace_in_parent_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """check finds the workspace by walking up from a subdirectory.""" + (tmp_path / ".archml-workspace.yaml").write_text("build-directory: .archml-build\n") + (tmp_path / "arch.archml").write_text("component MyComponent {}\n") + subdir = tmp_path / "src" / "components" + subdir.mkdir(parents=True) + monkeypatch.setattr(sys, "argv", ["archml", "check", str(subdir)]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + captured = capsys.readouterr() + assert "No issues found." in captured.out + + def test_check_fails_if_directory_does_not_exist(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """check exits with error code 1 when directory does not exist.""" missing = tmp_path / "nonexistent" @@ -298,13 +314,29 @@ def test_check_excludes_build_directory_from_scan( def test_serve_fails_if_no_workspace(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """serve exits with error code 1 when no workspace file is found.""" + """serve exits with error code 1 when no workspace file is found anywhere in the tree.""" monkeypatch.setattr(sys, "argv", ["archml", "serve", str(tmp_path)]) with pytest.raises(SystemExit) as exc_info: main() assert exc_info.value.code == 1 +def test_serve_autodetects_workspace_in_parent_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """serve finds the workspace by walking up from a subdirectory.""" + (tmp_path / ".archml-workspace.yaml").write_text("build-directory: .archml-build\n") + subdir = tmp_path / "src" / "components" + subdir.mkdir(parents=True) + monkeypatch.setattr(sys, "argv", ["archml", "serve", str(subdir)]) + mock_app = MagicMock() + with ( + patch("archml.webui.app.create_app", return_value=mock_app), + pytest.raises(SystemExit) as exc_info, + ): + main() + assert exc_info.value.code == 0 + mock_app.run.assert_called_once() + + def test_serve_fails_if_directory_does_not_exist(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """serve exits with error code 1 when directory does not exist.""" missing = tmp_path / "nonexistent" @@ -368,18 +400,23 @@ def test_sync_remote_fails_if_directory_does_not_exist(tmp_path: Path, monkeypat assert exc_info.value.code == 1 -def test_sync_remote_no_workspace_yaml(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """sync-remote exits 0 with a message when no workspace YAML is present.""" - (tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n") - monkeypatch.setattr(sys, "argv", ["archml", "sync-remote", str(tmp_path)]) +def test_sync_remote_autodetects_workspace_in_parent( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """sync-remote finds the workspace by walking up from a subdirectory.""" + (tmp_path / ".archml-workspace.yaml").write_text("build-directory: build\n") + subdir = tmp_path / "src" / "components" + subdir.mkdir(parents=True) + monkeypatch.setattr(sys, "argv", ["archml", "sync-remote", str(subdir)]) with pytest.raises(SystemExit) as exc_info: main() assert exc_info.value.code == 0 + captured = capsys.readouterr() + assert "Nothing to sync" in captured.out def test_sync_remote_no_git_imports(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """sync-remote exits 0 when no git imports are configured.""" - (tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n") (tmp_path / ".archml-workspace.yaml").write_text("build-directory: build\n") monkeypatch.setattr(sys, "argv", ["archml", "sync-remote", str(tmp_path)]) with pytest.raises(SystemExit) as exc_info: @@ -389,7 +426,6 @@ def test_sync_remote_no_git_imports(tmp_path: Path, monkeypatch: pytest.MonkeyPa def test_sync_remote_fails_without_lockfile(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """sync-remote exits 1 when the lockfile is missing.""" - (tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n") (tmp_path / ".archml-workspace.yaml").write_text( "build-directory: build\n" "source-imports:\n" @@ -407,7 +443,6 @@ def test_sync_remote_fails_if_repo_not_in_lockfile( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: """sync-remote exits 1 when a configured repo is missing from the lockfile.""" - (tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n") (tmp_path / ".archml-workspace.yaml").write_text( "build-directory: build\n" "source-imports:\n" @@ -428,7 +463,6 @@ def test_sync_remote_skips_repo_already_at_pinned_commit( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: """sync-remote skips a repo that is already at the pinned commit.""" - (tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n") (tmp_path / ".archml-workspace.yaml").write_text( "build-directory: build\n" "source-imports:\n" @@ -458,7 +492,6 @@ def test_sync_remote_clones_repo( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: """sync-remote calls clone_at_commit when repo is not at the pinned commit.""" - (tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n") (tmp_path / ".archml-workspace.yaml").write_text( "build-directory: build\n" "source-imports:\n" @@ -494,7 +527,6 @@ def test_sync_remote_reports_error_on_clone_failure( """sync-remote exits 1 and reports an error if clone_at_commit fails.""" from archml.workspace.git_ops import GitError - (tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n") (tmp_path / ".archml-workspace.yaml").write_text( "build-directory: build\n" "source-imports:\n" @@ -523,7 +555,6 @@ def test_sync_remote_reports_error_on_clone_failure( def test_sync_remote_uses_custom_sync_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """sync-remote uses the remote-sync-directory from workspace config.""" - (tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n") (tmp_path / ".archml-workspace.yaml").write_text( "build-directory: build\n" "remote-sync-directory: custom-remotes\n" @@ -574,18 +605,23 @@ def test_update_remote_fails_if_directory_does_not_exist(tmp_path: Path, monkeyp assert exc_info.value.code == 1 -def test_update_remote_no_workspace_yaml(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """update-remote exits 0 with a message when no workspace YAML is present.""" - (tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n") - monkeypatch.setattr(sys, "argv", ["archml", "update-remote", str(tmp_path)]) +def test_update_remote_autodetects_workspace_in_parent( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """update-remote finds the workspace by walking up from a subdirectory.""" + (tmp_path / ".archml-workspace.yaml").write_text("build-directory: build\n") + subdir = tmp_path / "src" / "components" + subdir.mkdir(parents=True) + monkeypatch.setattr(sys, "argv", ["archml", "update-remote", str(subdir)]) with pytest.raises(SystemExit) as exc_info: main() assert exc_info.value.code == 0 + captured = capsys.readouterr() + assert "Nothing to update" in captured.out def test_update_remote_no_git_imports(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """update-remote exits 0 when no git imports are configured.""" - (tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n") (tmp_path / ".archml-workspace.yaml").write_text("build-directory: build\n") monkeypatch.setattr(sys, "argv", ["archml", "update-remote", str(tmp_path)]) with pytest.raises(SystemExit) as exc_info: @@ -596,7 +632,6 @@ def test_update_remote_no_git_imports(tmp_path: Path, monkeypatch: pytest.Monkey def test_update_remote_creates_lockfile_from_branch(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """update-remote creates a lockfile by resolving branch revisions.""" resolved_commit = "c" * 40 - (tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n") (tmp_path / ".archml-workspace.yaml").write_text( "build-directory: build\n" "source-imports:\n" @@ -625,7 +660,6 @@ def test_update_remote_creates_lockfile_from_branch(tmp_path: Path, monkeypatch: def test_update_remote_pins_commit_hash_without_network(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """update-remote does not call git for revisions that are already commit hashes.""" - (tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n") (tmp_path / ".archml-workspace.yaml").write_text( "build-directory: build\n" "source-imports:\n" @@ -652,7 +686,6 @@ def test_update_remote_updates_existing_lockfile(tmp_path: Path, monkeypatch: py """update-remote updates an existing lockfile with new commits.""" old_commit = "0" * 40 new_commit = "1" * 40 - (tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n") (tmp_path / ".archml-workspace.yaml").write_text( "build-directory: build\n" "source-imports:\n" @@ -687,7 +720,6 @@ def test_update_remote_exits_1_on_resolution_failure( """update-remote exits 1 if a revision cannot be resolved.""" from archml.workspace.git_ops import GitError - (tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n") (tmp_path / ".archml-workspace.yaml").write_text( "build-directory: build\n" "source-imports:\n" @@ -714,7 +746,6 @@ def test_check_command_uses_synced_remote_repos( remote_dir.mkdir(parents=True) (remote_dir / "api.archml").write_text("interface PaymentAPI { field amount: Decimal }\n") - (tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n") (tmp_path / ".archml-workspace.yaml").write_text( "build-directory: build\n" "source-imports:\n" @@ -745,7 +776,6 @@ def test_check_command_loads_remote_repo_mnemonics( "build-directory: build\nsource-imports:\n - name: lib\n local-path: src/lib\n" ) - (tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n") (tmp_path / ".archml-workspace.yaml").write_text( "build-directory: build\n" "source-imports:\n" @@ -775,7 +805,6 @@ def test_check_command_warns_on_invalid_remote_workspace_yaml( # Malformed remote workspace config (remote_dir / ".archml-workspace.yaml").write_text("bad yaml: [unterminated\n") - (tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n") (tmp_path / ".archml-workspace.yaml").write_text( "build-directory: build\n" "source-imports:\n" diff --git a/tests/workspace/test_config.py b/tests/workspace/test_config.py index 02b11b9..d73b81a 100644 --- a/tests/workspace/test_config.py +++ b/tests/workspace/test_config.py @@ -10,6 +10,7 @@ LocalPathImport, WorkspaceConfig, WorkspaceConfigError, + find_workspace_root, load_workspace_config, ) @@ -228,3 +229,56 @@ def test_load_config_default_remote_sync_directory(tmp_path): config = load_workspace_config(cfg_file) assert config.remote_sync_directory == ".archml-remotes" + + +def test_find_workspace_root_in_current_directory(tmp_path): + """find_workspace_root returns the directory itself when it contains the workspace file.""" + (tmp_path / ".archml-workspace.yaml").write_text("build-directory: build\n", encoding="utf-8") + + result = find_workspace_root(tmp_path) + + assert result == tmp_path + + +def test_find_workspace_root_in_parent_directory(tmp_path): + """find_workspace_root walks up and finds the workspace in a parent directory.""" + (tmp_path / ".archml-workspace.yaml").write_text("build-directory: build\n", encoding="utf-8") + child_dir = tmp_path / "subdir" / "nested" + child_dir.mkdir(parents=True) + + result = find_workspace_root(child_dir) + + assert result == tmp_path + + +def test_find_workspace_root_returns_none_when_not_found(tmp_path): + """find_workspace_root returns None when no workspace file exists anywhere in the tree.""" + child_dir = tmp_path / "subdir" + child_dir.mkdir() + + result = find_workspace_root(child_dir) + + assert result is None + + +def test_find_workspace_root_uses_nearest_ancestor(tmp_path): + """find_workspace_root returns the closest (innermost) workspace directory.""" + (tmp_path / ".archml-workspace.yaml").write_text("build-directory: outer\n", encoding="utf-8") + inner_dir = tmp_path / "inner" + inner_dir.mkdir() + (inner_dir / ".archml-workspace.yaml").write_text("build-directory: inner\n", encoding="utf-8") + nested = inner_dir / "deep" + nested.mkdir() + + result = find_workspace_root(nested) + + assert result == inner_dir + + +def test_find_workspace_root_returns_given_dir_when_it_is_the_root(tmp_path): + """find_workspace_root resolves the start directory and finds the workspace in it.""" + (tmp_path / ".archml-workspace.yaml").write_text("build-directory: build\n", encoding="utf-8") + + result = find_workspace_root(tmp_path / ".") + + assert result == tmp_path