diff --git a/architecture/ADR/0020-cpt-studio-adr-rebrand-and-mirror-override-v1.md b/architecture/ADR/0020-cpt-studio-adr-rebrand-and-mirror-override-v1.md index 96648396..73ad8eb4 100644 --- a/architecture/ADR/0020-cpt-studio-adr-rebrand-and-mirror-override-v1.md +++ b/architecture/ADR/0020-cpt-studio-adr-rebrand-and-mirror-override-v1.md @@ -81,7 +81,7 @@ The following renames are applied across all architecture docs, source code, and | Cache directory | `~/.cf-constructor/cache/` | `~/.cf-studio/cache/` | | Default GitHub repo | `cyberfabric/cyber-constructor` | `constructorfabric/studio` | | Default kit-sdlc repo | `cyberfabric/cyber-pilot-kit-sdlc` / `cyberfabric/cyber-constructor-kit-sdlc` | `constructorfabric/studio-kit-sdlc` | -| Workspace marker file | `.cypilot-workspace.toml` | `.studio-workspace.toml` | +| Workspace marker file | `.cypilot-workspace.toml` | `.cf-workspace.toml` (canonical; `.studio-workspace.toml` is legacy fallback) | | VS Code workspace file | `Cypilot.code-workspace` | `Studio.code-workspace` | | Skill name (canonical) | `cf-constructor` | `cf` (alias `cf-studio`) | | Skill directory | `skills/cypilot/` | `skills/studio/` | @@ -137,7 +137,7 @@ Confirmed when: - `cfs generate-agents` produces host integration files under agent-specific locations such as `.agents/skills/cf/` and `.claude/skills/cf/`; canonical source files remain under `skills/studio/` - `cfs mirror override github.com/constructorfabric/studio github.com/myorg/studio` writes to XDG path on fresh install - `cfs mirror list` shows merged set with correct source path for each entry -- Migrating a cypilot 3.9.0 project writes `.studio-workspace.toml` directly (no intermediate Cyber Constructor form) +- Migrating a cypilot 3.9.0 project writes canonical `.cf-workspace.toml` directly (no intermediate Cyber Constructor form); `.studio-workspace.toml` remains a legacy discovery fallback - All `cpt-cypilot-*` ID references in migrated projects are rewritten to `cpt-studio-*` ## Pros and Cons of the Options diff --git a/guides/MIGRATING-FROM-CYPILOT.md b/guides/MIGRATING-FROM-CYPILOT.md index d6a15eb7..1d0dbf51 100644 --- a/guides/MIGRATING-FROM-CYPILOT.md +++ b/guides/MIGRATING-FROM-CYPILOT.md @@ -41,7 +41,7 @@ The orchestrator loads the shared Studio runtime first, then dispatches scanner, **Step 6. E2 — Planner (read-only).** Approve the planner dispatch when prompted. `cf-migrate-planner` groups findings into three categories: - **A** — auto-fixable (unambiguous string substitutions); - **B** — needs-review (context-sensitive); -- **C** — cascade (rename to `.studio-workspace.toml`, cascade migration into workspace member repos, regenerate IDE integrations via `cfs generate-agents`). +- **C** — cascade (review legacy workspace markers and, when desired, rename them to canonical `.cf-workspace.toml`; cascade migration into workspace member repos, regenerate IDE integrations via `cfs generate-agents`). Legacy `.studio-workspace.toml` remains a discovery fallback. **Step 7. E3 — Migrator (write).** The orchestrator prints the full plan, then offers a menu: - `1` — apply category A only; @@ -50,7 +50,7 @@ The orchestrator loads the shared Studio runtime first, then dispatches scanner, - `4` — pick specific items; - `N` — skip. -A safe starting choice is `1`. The migrator writes using Constructor Studio target identifiers: `.studio-workspace.toml`, `skills/studio/`, CLI `cfs`, cache `~/.cf-studio/cache/`, registry `constructorfabric/studio`. +A safe starting choice is `1`. The migrator writes using Constructor Studio target identifiers: canonical `.cf-workspace.toml`, `skills/studio/`, CLI `cfs`, cache `~/.cf-studio/cache/`, registry `constructorfabric/studio`. Legacy `.studio-workspace.toml` remains a discovery fallback. Before the write-capable migrator runs, Studio resolves the session git write policy. Choose `commit`, `stage`, or `none`. The migration can still inspect git state in `none` mode, but it must not stage or commit unless the selected policy permits it and the workflow asks for that action. diff --git a/schemas/workspace.schema.json b/schemas/workspace.schema.json index e9fa82de..7f53764b 100644 --- a/schemas/workspace.schema.json +++ b/schemas/workspace.schema.json @@ -1,8 +1,8 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://example.com/constructor-studio/workspace.schema.json", - "title": "Constructor Studio Workspace Configuration (.studio-workspace.toml)", - "description": "Federation layer for multi-repo workspace support. Maps named sources (repos) to local paths with roles. Standalone file uses TOML format.", + "title": "Constructor Studio Workspace Configuration (.cf-workspace.toml)", + "description": "Federation layer for multi-repo workspace support. Maps named sources (repos) to local paths with roles. The canonical standalone filename is .cf-workspace.toml; .studio-workspace.toml remains supported as a legacy automatic discovery fallback and when supplied explicitly through workspace = \"\" in config/core.toml.", "type": "object", "additionalProperties": false, "required": ["version", "sources"], diff --git a/skills/studio/scripts/studio/commands/workspace_info.py b/skills/studio/scripts/studio/commands/workspace_info.py index d7641bd7..6155ddfd 100644 --- a/skills/studio/scripts/studio/commands/workspace_info.py +++ b/skills/studio/scripts/studio/commands/workspace_info.py @@ -9,10 +9,26 @@ from typing import List, Optional from ..utils.git_utils import _redact_url, peek_git_source_path +from ..utils.stderr_logging import emit_stderr_message from ..utils.ui import ui from ..utils.workspace import ResolveConfig, WorkspaceConfig +_LEGACY_WORKSPACE_WARNING = ( + "Legacy .studio-workspace.toml was discovered automatically; rename it to " + ".cf-workspace.toml or configure workspace = \"\" in config/core.toml." +) + + +def _warn_legacy_workspace_fallback(ws_cfg: WorkspaceConfig) -> None: + """Emit the migration warning only for automatic legacy-file discovery.""" + if ws_cfg.is_legacy_fallback: + emit_stderr_message( + f"WARNING: {_LEGACY_WORKSPACE_WARNING}\n", + logger_name=f"{__name__}.stderr", + ) + + def _source_warning_for_result(info: dict) -> Optional[str]: """Return a normalized top-level warning string for a source info record.""" warning = info.get("warning") @@ -215,6 +231,7 @@ def cmd_workspace_info(argv: List[str]) -> int: project_root, ws_cfg = _load_workspace_info_context() if project_root is None or ws_cfg is None: return 1 + _warn_legacy_workspace_fallback(ws_cfg) # @cpt-begin:cpt-studio-flow-workspace-info:p1:inst-info-foreach-source sources_info = [_build_source_info(ws_cfg, name) for name in ws_cfg.sources] diff --git a/skills/studio/scripts/studio/commands/workspace_init.py b/skills/studio/scripts/studio/commands/workspace_init.py index 0f27d961..e4050f66 100644 --- a/skills/studio/scripts/studio/commands/workspace_init.py +++ b/skills/studio/scripts/studio/commands/workspace_init.py @@ -8,17 +8,32 @@ import os import re from pathlib import Path -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, Optional, Tuple, TYPE_CHECKING +from ..utils.stderr_logging import emit_stderr_message from ..utils.ui import ui +if TYPE_CHECKING: + from ..utils.workspace import WorkspaceConfig + logger = logging.getLogger(__name__) +_LEGACY_WORKSPACE_WARNING = ( + "Legacy .studio-workspace.toml was discovered automatically; rename it to " + ".cf-workspace.toml or configure workspace = \"\" in config/core.toml." +) + + def _warn_workspace_init(message: str) -> None: logger.warning("workspace-init: %s", message) +def _emit_stderr(message: str) -> None: + """Emit a line through a handler bound to the current stderr.""" + emit_stderr_message(message, logger_name=f"{__name__}.stderr") + + def _is_project_dir(entry: Path) -> bool: """Check if a directory looks like a project (has .git or AGENTS.md with marker).""" if (entry / ".git").exists(): @@ -275,34 +290,91 @@ def _resolve_output_dir(args: argparse.Namespace, scan_root: Path, project_root: return scan_root -def _check_existing_workspace(project_root: Path, *, inline: bool, force: bool) -> Optional[str]: - """Check for existing workspace config conflicts. Returns error message or None.""" +def _check_existing_workspace( + project_root: Path, *, inline: bool, force: bool +) -> Tuple[Optional["WorkspaceConfig"], Optional[str]]: + """Check for existing workspace config conflicts. Returns (workspace_config, error).""" from ..utils.workspace import find_workspace_config as _find_ws existing_ws, ws_err = _find_ws(project_root) if existing_ws is None: if ws_err: - return f"Existing workspace config is broken: {ws_err}. Fix or remove it before reinitializing." - return None + return None, ( + f"Existing workspace config is broken: {ws_err}. " + "Fix or remove it before reinitializing." + ) + return None, None + + if existing_ws.is_legacy_fallback: + _emit_stderr(f"WARNING: {_LEGACY_WORKSPACE_WARNING}\n") if inline and not existing_ws.is_inline: - return ( + return existing_ws, ( f"Standalone workspace already exists at {existing_ws.workspace_file}. " "Cannot create inline workspace — this would create parallel configs. " "Delete the standalone file first, then retry with --inline." ) if not inline and existing_ws.is_inline: - return ( + return existing_ws, ( "Inline workspace already exists in core.toml. " "Cannot create standalone workspace — this would create parallel configs. " "Remove the [workspace] section from core.toml first, then retry." ) if not force: config_loc = "core.toml" if existing_ws.is_inline else str(existing_ws.workspace_file) - return ( + return existing_ws, ( f"Workspace already exists ({config_loc}). " "Use --force to reinitialize (this will overwrite existing workspace config)." ) + return existing_ws, None + + +def _workspace_hint_path(project_root: Path, output_path: Path) -> str: + """Format a workspace path for the config/core.toml workspace hint.""" + rel = output_path.relative_to(project_root) if output_path.is_relative_to(project_root) else output_path + return rel.as_posix() + + +def _replace_legacy_workspace_marker( + canonical_path: Path, + workspace_data: dict, + existing_ws: Optional["WorkspaceConfig"], + canonical_filename: str, +) -> Optional[dict]: + """Replace automatic legacy fallback marker when writing its canonical sibling.""" + if ( + existing_ws is None + or not existing_ws.is_legacy_fallback + or existing_ws.workspace_file is None + ): + return None + legacy_path = existing_ws.workspace_file.resolve() + if canonical_path != legacy_path.with_name(canonical_filename): + return None + try: + legacy_path.unlink() + except OSError as exc: + rollback_exc = None + try: + canonical_path.unlink() + except OSError as rollback_error: + rollback_exc = rollback_error + rollback_suffix = ( + f" Rollback also failed for {canonical_path}: {rollback_exc}." + if rollback_exc is not None + else f" The new canonical marker at {canonical_path} was rolled back." + ) + return { + "status": "ERROR", + "message": ( + f"Failed to replace legacy automatic fallback marker {legacy_path}: {exc}." + f"{rollback_suffix} Resolve the marker collision before retrying." + ), + "config_path": str(canonical_path), + "workspace": workspace_data, + "sources_count": len(workspace_data.get("sources", {})), + "sources": list(workspace_data.get("sources", {}).keys()), + } return None # @cpt-flow:cpt-studio-flow-workspace-init:p1 @@ -337,6 +409,7 @@ def _write_workspace_config( project_root: Path, scan_root: Path, workspace_data: dict, + existing_ws: Optional["WorkspaceConfig"] = None, ) -> tuple: """Execute workspace write (inline or standalone). Returns (exit_code, data).""" from ..constants import WORKSPACE_CONFIG_FILENAME @@ -353,11 +426,21 @@ def _write_workspace_config( # @cpt-begin:cpt-studio-state-workspace-config-lifecycle:p1:inst-config-reinit-standalone output_path = Path(output_arg).resolve() if output_arg else (scan_root / WORKSPACE_CONFIG_FILENAME) exit_code, data = _write_standalone(output_path, workspace_data) - if output_arg and not exit_code: - rel = output_path.relative_to(project_root) if output_path.is_relative_to(project_root) else output_path + canonical_path = output_path.resolve() + if not exit_code: + replace_error = _replace_legacy_workspace_marker( + canonical_path, + workspace_data, + existing_ws, + WORKSPACE_CONFIG_FILENAME, + ) + if replace_error is not None: + return 1, replace_error + project_root_canonical_path = (project_root / WORKSPACE_CONFIG_FILENAME).resolve() + if output_arg and not exit_code and canonical_path != project_root_canonical_path: data["hint"] = ( - f"Custom output path used. Other commands will not discover this file automatically. " - f'Add \'workspace = "{rel}"\' to config/core.toml to enable discovery.' + "Custom output path used. Automatic discovery requires " + f'workspace = "{_workspace_hint_path(project_root, output_path)}" in config/core.toml.' ) # @cpt-end:cpt-studio-state-workspace-config-lifecycle:p1:inst-config-reinit-standalone # @cpt-end:cpt-studio-flow-workspace-init:p1:inst-else-standalone @@ -460,14 +543,14 @@ def _positive_int(value: str) -> int: # @cpt-begin:cpt-studio-flow-workspace-init:p1:inst-if-existing-ws # Check for existing workspace — prevent parallel configs and accidental overwrites - conflict_err = _check_existing_workspace(project_root, inline=args.inline, force=args.force) + existing_ws, conflict_err = _check_existing_workspace(project_root, inline=args.inline, force=args.force) if conflict_err: ui.result({"status": "ERROR", "message": conflict_err}) return 1 # @cpt-end:cpt-studio-flow-workspace-init:p1:inst-if-existing-ws exit_code, data = _write_workspace_config( - args.inline, args.output, project_root, scan_root, workspace_data, + args.inline, args.output, project_root, scan_root, workspace_data, existing_ws, ) # @cpt-begin:cpt-studio-flow-workspace-init:p1:inst-return-init-ok diff --git a/skills/studio/scripts/studio/utils/workspace.py b/skills/studio/scripts/studio/utils/workspace.py index b4136fbe..f2b62ff8 100644 --- a/skills/studio/scripts/studio/utils/workspace.py +++ b/skills/studio/scripts/studio/utils/workspace.py @@ -293,6 +293,7 @@ class WorkspaceConfig: workspace_file: Optional[Path] = None # Absolute path to the workspace file is_inline: bool = False # True if loaded from core.toml inline workspace resolution_base: Optional[Path] = None # Override for source path resolution base directory + is_legacy_fallback: bool = False # True only for automatic legacy-file discovery @classmethod def from_dict( @@ -544,7 +545,8 @@ def find_workspace_config(project_root: Path) -> Tuple[Optional[WorkspaceConfig] 1. 'workspace' key in project config (core.toml via AGENTS.md): - If string: treat as path to external workspace file - If dict: treat as inline workspace definition - 2. Standalone .cf-workspace.toml at project_root + 2. Standalone .cf-workspace.toml at project_root, with + .studio-workspace.toml as a compatibility fallback Args: project_root: The project root directory. @@ -603,20 +605,29 @@ def _find_standalone_workspace( ) -> Tuple[Optional[WorkspaceConfig], Optional[str]]: """Fallback: discover standalone workspace TOML at project root. - Checks in order: - 1. .cf-workspace.toml (canonical name) - 2. .studio-workspace.toml (legacy / test-fixture name) + Checks both filenames before loading either: + 1. .cf-workspace.toml (canonical name) + 2. .studio-workspace.toml (legacy compatibility fallback) """ candidate = (project_root / WORKSPACE_CONFIG_FILENAME).resolve() - if candidate.is_file(): + legacy = (project_root / _LEGACY_WORKSPACE_FILENAME).resolve() + has_canonical = candidate.is_file() + has_legacy = legacy.is_file() + if has_canonical and has_legacy: + return None, ( + "Found both standalone workspace markers: " + f"{candidate} and {legacy}. Remove one or configure workspace explicitly in core.toml." + ) + if has_canonical: # @cpt-begin:cpt-studio-algo-workspace-find-config:p1:inst-find-return-standalone ws_cfg, ws_err = WorkspaceConfig.load(candidate) return ws_cfg, ws_err # @cpt-end:cpt-studio-algo-workspace-find-config:p1:inst-find-return-standalone - legacy = (project_root / _LEGACY_WORKSPACE_FILENAME).resolve() - if legacy.is_file(): + if has_legacy: # @cpt-begin:cpt-studio-algo-workspace-find-config:p1:inst-find-return-standalone ws_cfg, ws_err = WorkspaceConfig.load(legacy) + if ws_cfg is not None: + ws_cfg.is_legacy_fallback = True return ws_cfg, ws_err # @cpt-end:cpt-studio-algo-workspace-find-config:p1:inst-find-return-standalone return None, None diff --git a/tests/test_cli_workspace_diag_e2e.py b/tests/test_cli_workspace_diag_e2e.py index 990d69e9..de6f710f 100644 --- a/tests/test_cli_workspace_diag_e2e.py +++ b/tests/test_cli_workspace_diag_e2e.py @@ -138,8 +138,114 @@ def test_workspace_init_output_writes_custom_location_only(self): self.assertEqual(payload["status"], "CREATED") self.assertEqual(payload["config_path"], str(output_path.resolve())) self.assertIn("Custom output path used", payload["hint"]) + self.assertIn('workspace = "generated/custom-workspace.toml"', payload["hint"]) self.assertFalse((root / ".cf-workspace.toml").exists()) + def test_workspace_init_accepts_legacy_output_path_with_explicit_discovery_hint(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + _make_adapter_repo(root / "docs-repo", role_dir="architecture") + output_path = root / ".studio-workspace.toml" + + exit_code, stdout, stderr = _run_main( + ["--json", "workspace-init", "--output", str(output_path)], + cwd=root, + ) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr, "") + payload = json.loads(stdout) + self.assertEqual(payload["config_path"], str(output_path.resolve())) + self.assertIn('workspace = ".studio-workspace.toml"', payload["hint"]) + self.assertTrue(output_path.is_file()) + + def test_workspace_init_force_replaces_automatic_legacy_fallback_without_collision(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + _make_adapter_repo(root / "docs-repo", role_dir="architecture") + toml_utils.dump( + { + "version": "1.0", + "sources": {"docs": {"path": "docs", "role": "artifacts"}}, + }, + root / ".studio-workspace.toml", + ) + + exit_code, stdout, stderr = _run_main(["--json", "workspace-init", "--force"], cwd=root) + + self.assertEqual(exit_code, 0) + self.assertIn("Legacy .studio-workspace.toml", stderr) + self.assertTrue((root / ".cf-workspace.toml").is_file()) + self.assertFalse((root / ".studio-workspace.toml").exists()) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "CREATED") + + info_exit_code, info_stdout, info_stderr = _run_main(["--json", "workspace-info"], cwd=root) + self.assertEqual(info_exit_code, 0) + self.assertEqual(info_stderr, "") + self.assertEqual(json.loads(info_stdout)["status"], "OK") + + def test_workspace_init_force_explicit_canonical_output_replaces_automatic_legacy_fallback(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + _make_adapter_repo(root / "docs-repo", role_dir="architecture") + toml_utils.dump( + { + "version": "1.0", + "sources": {"docs": {"path": "docs", "role": "artifacts"}}, + }, + root / ".studio-workspace.toml", + ) + output_path = root / ".cf-workspace.toml" + + exit_code, stdout, stderr = _run_main( + ["--json", "workspace-init", "--force", "--output", str(output_path)], + cwd=root, + ) + + self.assertEqual(exit_code, 0) + self.assertIn("Legacy .studio-workspace.toml", stderr) + self.assertTrue(output_path.is_file()) + self.assertFalse((root / ".studio-workspace.toml").exists()) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "CREATED") + self.assertNotIn("hint", payload) + + info_exit_code, info_stdout, info_stderr = _run_main(["--json", "workspace-info"], cwd=root) + self.assertEqual(info_exit_code, 0) + self.assertEqual(info_stderr, "") + self.assertEqual(json.loads(info_stdout)["status"], "OK") + + def test_workspace_init_force_same_directory_custom_output_keeps_automatic_legacy_fallback(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + _make_adapter_repo(root / "docs-repo", role_dir="architecture") + toml_utils.dump( + { + "version": "1.0", + "sources": {"docs": {"path": "docs", "role": "artifacts"}}, + }, + root / ".studio-workspace.toml", + ) + output_path = root / "custom.toml" + + exit_code, stdout, stderr = _run_main( + ["--json", "workspace-init", "--force", "--output", str(output_path)], + cwd=root, + ) + + self.assertEqual(exit_code, 0) + self.assertIn("Legacy .studio-workspace.toml", stderr) + self.assertTrue(output_path.is_file()) + self.assertTrue((root / ".studio-workspace.toml").is_file()) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "CREATED") + self.assertIn('workspace = "custom.toml"', payload["hint"]) + def test_workspace_init_inline_and_output_are_mutually_exclusive(self): with TemporaryDirectory() as tmpdir: root = Path(tmpdir) / "workspace-root" @@ -214,6 +320,30 @@ def test_workspace_init_force_overwrites_existing_workspace(self): self.assertNotIn("stale-source", workspace_data["sources"]) self.assertEqual(set(workspace_data["sources"]), {"docs-repo", "shared-lib"}) + def test_workspace_init_warns_when_legacy_marker_is_automatic_fallback(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + _make_adapter_repo(root / "docs-repo", role_dir="architecture") + toml_utils.dump( + { + "version": "1.0", + "sources": {"docs": {"path": "docs", "role": "artifacts"}}, + }, + root / ".studio-workspace.toml", + ) + + exit_code, stdout, stderr = _run_main(["--json", "workspace-init", "--force"], cwd=root) + + self.assertEqual(exit_code, 0) + self.assertIn("Legacy .studio-workspace.toml", stderr) + self.assertIn(".cf-workspace.toml", stderr) + self.assertIn('workspace = ""', stderr) + payload = json.loads(stdout) + self.assertEqual(payload["status"], "CREATED") + self.assertEqual(payload["sources"], ["docs-repo"]) + self.assertFalse((root / ".studio-workspace.toml").exists()) + def test_workspace_init_max_depth_excludes_deeper_repos(self): with TemporaryDirectory() as tmpdir: root = Path(tmpdir) / "workspace-root" @@ -585,6 +715,26 @@ def test_workspace_info_git_source_not_cloned_reports_warning_without_network(se self.assertFalse(source["reachable"]) self.assertIn("Source not cloned", source["warning"]) + def test_workspace_info_warns_when_legacy_marker_is_automatic_fallback(self): + with TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "workspace-root" + _make_repo(root) + toml_utils.dump( + { + "version": "1.0", + "sources": {"docs": {"path": "docs", "role": "artifacts"}}, + }, + root / ".studio-workspace.toml", + ) + + exit_code, stdout, stderr = _run_main(["--json", "workspace-info"], cwd=root) + + self.assertEqual(exit_code, 0) + self.assertIn("Legacy .studio-workspace.toml", stderr) + self.assertIn(".cf-workspace.toml", stderr) + self.assertIn('workspace = ""', stderr) + self.assertEqual(json.loads(stdout)["status"], "OK") + def test_workspace_info_metadata_error_marks_workspace_degraded(self): with TemporaryDirectory() as tmpdir: root = Path(tmpdir) / "workspace-root" diff --git a/tests/test_workspace.py b/tests/test_workspace.py index 57506ef3..03650894 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -16,7 +16,7 @@ import json import pytest import sys -from pathlib import Path +from pathlib import Path, PureWindowsPath from tempfile import TemporaryDirectory from unittest.mock import MagicMock, patch @@ -69,7 +69,9 @@ _scan_nested_repos, _write_standalone, _write_inline, + _write_workspace_config, _check_existing_workspace, + _workspace_hint_path, _human_workspace_init, cmd_workspace_init, ) @@ -734,8 +736,62 @@ def test_standalone_file_discovered_at_project_root(self): assert err is None assert cfg is not None assert cfg.is_inline is False + assert cfg.is_legacy_fallback is False assert "lib" in cfg.sources + def test_legacy_standalone_file_is_discovered_as_fallback(self): + with TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + legacy_path = tmp / ".studio-workspace.toml" + toml_utils.dump({ + "version": "1.0", + "sources": {"lib": {"path": "../lib"}}, + }, legacy_path) + + cfg, err = find_workspace_config(tmp) + + assert err is None + assert cfg is not None + assert cfg.workspace_file == legacy_path.resolve() + assert cfg.is_legacy_fallback is True + + def test_both_standalone_markers_return_error_without_loading_either(self): + with TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + for filename in (".cf-workspace.toml", ".studio-workspace.toml"): + toml_utils.dump({ + "version": "1.0", + "sources": {"lib": {"path": "../lib"}}, + }, tmp / filename) + + cfg, err = find_workspace_config(tmp) + + assert cfg is None + assert err is not None + assert "both standalone workspace markers" in err.lower() + + def test_explicit_legacy_path_wins_without_fallback_flag(self): + with TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + legacy_path = tmp / ".studio-workspace.toml" + toml_utils.dump({ + "version": "1.0", + "sources": {"configured": {"path": "../configured"}}, + }, legacy_path) + toml_utils.dump({ + "version": "1.0", + "sources": {"automatic": {"path": "../automatic"}}, + }, tmp / ".cf-workspace.toml") + self._setup_v3_project(tmp, {"workspace": ".studio-workspace.toml"}) + + cfg, err = find_workspace_config(tmp) + + assert err is None + assert cfg is not None + assert cfg.workspace_file == legacy_path.resolve() + assert cfg.is_legacy_fallback is False + assert "configured" in cfg.sources + def test_standalone_file_not_discovered_at_parent(self): """Standalone .cf-constructor-workspace.toml one level above project root is NOT discovered (no parent walk-up).""" with TemporaryDirectory() as tmpdir: @@ -2144,6 +2200,7 @@ def _run_init_with_existing_ws(capsys, argv, *, is_inline=False): with TemporaryDirectory() as tmpdir: mock_ws = MagicMock() mock_ws.is_inline = is_inline + mock_ws.is_legacy_fallback = False mock_ws.workspace_file = Path(tmpdir) / ".cf-constructor-workspace.toml" root = Path(tmpdir) with patch("studio.utils.files.find_project_root", return_value=root): @@ -2712,6 +2769,81 @@ def test_resolve_inline_workspace_creates_default_sources(self): assert ws["version"] == "1.0" assert ws["sources"] == {} + +class TestWorkspaceInitHints: + def test_workspace_hint_path_uses_forward_slashes_for_windows_relative_paths(self): + project_root = PureWindowsPath("C:/repo") + output_path = PureWindowsPath("C:/repo/generated/custom-workspace.toml") + + hint_path = _workspace_hint_path(project_root, output_path) + + assert hint_path == "generated/custom-workspace.toml" + assert "\\" not in hint_path + parsed = toml_utils.loads(f'workspace = "{hint_path}"\n') + assert parsed["workspace"] == hint_path + + def test_write_workspace_config_keeps_legacy_marker_for_different_scan_root_default_output(self): + with TemporaryDirectory() as tmpdir: + project_root = Path(tmpdir) / "project-root" + scan_root = project_root / "nested" + project_root.mkdir(parents=True, exist_ok=True) + scan_root.mkdir(parents=True, exist_ok=True) + legacy_path = project_root / ".studio-workspace.toml" + legacy_path.write_text('version = "1.0"\n[sources]\n', encoding="utf-8") + existing_ws = WorkspaceConfig( + workspace_file=legacy_path.resolve(), + is_legacy_fallback=True, + ) + + exit_code, data = _write_workspace_config( + False, + None, + project_root, + scan_root, + {"version": "1.0", "sources": {}}, + existing_ws, + ) + + assert exit_code == 0 + assert data["status"] == "CREATED" + assert legacy_path.is_file() + assert (scan_root / ".cf-workspace.toml").is_file() + + def test_write_workspace_config_rolls_back_canonical_when_legacy_cleanup_fails(self): + with TemporaryDirectory() as tmpdir: + project_root = Path(tmpdir) + legacy_path = project_root / ".studio-workspace.toml" + canonical_path = project_root / ".cf-workspace.toml" + legacy_path.write_text('version = "1.0"\n[sources]\n', encoding="utf-8") + existing_ws = WorkspaceConfig( + workspace_file=legacy_path.resolve(), + is_legacy_fallback=True, + ) + original_unlink = Path.unlink + + with patch.object(Path, "unlink", autospec=True) as unlink_mock: + def _unlink_side_effect(path_obj): + if path_obj == legacy_path.resolve(): + raise OSError("legacy locked") + return original_unlink(path_obj) + + unlink_mock.side_effect = _unlink_side_effect + exit_code, data = _write_workspace_config( + False, + str(canonical_path), + project_root, + project_root, + {"version": "1.0", "sources": {}}, + existing_ws, + ) + + assert exit_code == 1 + assert data["status"] == "ERROR" + assert "legacy locked" in data["message"] + assert "rolled back" in data["message"] + assert legacy_path.is_file() + assert not canonical_path.exists() + def test_resolve_inline_workspace_rejects_malformed_workspace_type(self): ws, err = _resolve_inline_workspace({"workspace": []}) assert ws == {}