Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 48 additions & 24 deletions pdd/update_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ def resolve_prompt_code_pair(code_file_path: str, quiet: bool = False, output_di
Derives the corresponding prompt file path from a code file path.
Searches for and creates prompts only in the specified output directory or 'prompts' directory.
If the prompt file does not exist, it creates an empty one in the target directory.
Preserves the subdirectory structure of the code file relative to the repository root.

Args:
code_file_path: Path to the code file
Expand All @@ -50,48 +51,71 @@ def resolve_prompt_code_pair(code_file_path: str, quiet: bool = False, output_di
# Extract the filename without extension and directory
code_filename = os.path.basename(code_file_path)
base_name, _ = os.path.splitext(code_filename)

code_file_abs_path = os.path.abspath(code_file_path)
code_dir = os.path.dirname(code_file_abs_path)

# Determine the output directory
# Find the repository root (where the code file is located)
# This is needed for relative path calculation to preserve structure
repo_root = code_dir
try:
import git
repo = git.Repo(code_dir, search_parent_directories=True)
repo_root = repo.working_tree_dir
except:
# If not a git repo, use the directory containing the code file
pass

# Determine the base prompts directory
if output_dir:
# Use the custom output directory (absolute path)
prompts_dir = os.path.abspath(output_dir)
base_prompts_dir = os.path.abspath(output_dir)
else:
# Find the repository root (where the code file is located)
code_file_abs_path = os.path.abspath(code_file_path)
code_dir = os.path.dirname(code_file_abs_path)

# For repository mode, find the actual repo root
repo_root = code_dir
try:
import git
repo = git.Repo(code_dir, search_parent_directories=True)
repo_root = repo.working_tree_dir
except:
# If not a git repo, use the directory containing the code file
pass

# Use context-aware prompts_dir from .pddrc if available
context_name, context_config = detect_context_for_file(code_file_path, repo_root)
prompts_dir_config = context_config.get("prompts_dir", "prompts")
if os.path.isabs(prompts_dir_config):
prompts_dir = prompts_dir_config
base_prompts_dir = prompts_dir_config
else:
prompts_dir = os.path.join(repo_root, prompts_dir_config)
base_prompts_dir = os.path.join(repo_root, prompts_dir_config)

# Calculate relative path from repo_root to code_dir to preserve structure
try:
rel_dir = os.path.relpath(code_dir, repo_root)
if rel_dir == ".":
rel_dir = ""
else:
# If context has a code root (generate_output_path), strip that prefix
# E.g., for pdd/commands/file.py with generate_output_path="pdd",
# strip "pdd/" to get "commands/"
code_root = context_config.get("generate_output_path", "")
if code_root and rel_dir.startswith(code_root + os.sep):
# Strip the code root prefix
rel_dir = rel_dir[len(code_root) + len(os.sep):]
elif code_root and rel_dir == code_root:
# File is directly in code root
rel_dir = ""
except ValueError:
# Can happen on Windows if paths are on different drives
rel_dir = ""

# Construct the final directory including the relative structure
final_prompts_dir = os.path.join(base_prompts_dir, rel_dir)

# Construct the prompt filename in the determined directory
prompt_filename = f"{base_name}_{language}.prompt"
prompt_path_str = os.path.join(prompts_dir, prompt_filename)
prompt_path_str = os.path.join(final_prompts_dir, prompt_filename)
prompt_path = Path(prompt_path_str)

# Ensure prompts directory exists
prompts_path = Path(prompts_dir)
prompts_path = Path(final_prompts_dir)
if not prompts_path.exists():
try:
prompts_path.mkdir(parents=True, exist_ok=True)
if not quiet:
console.print(f"[success]Created prompts directory:[/success] [path]{prompts_dir}[/path]")
console.print(f"[success]Created prompts directory:[/success] [path]{final_prompts_dir}[/path]")
except OSError as e:
console.print(f"[error]Failed to create prompts directory {prompts_dir}: {e}[/error]")
console.print(f"[error]Failed to create prompts directory {final_prompts_dir}: {e}[/error]")

if not prompt_path.exists():
try:
Expand Down Expand Up @@ -222,7 +246,7 @@ def update_file_pair(prompt_file: str, code_file: str, ctx: click.Context, repo:
temperature=ctx.obj.get("temperature", 0),
verbose=verbose,
time=ctx.obj.get('time', DEFAULT_TIME),
simple=True, # Force legacy since we already tried agentic
simple=True, # Force legacy since we already tried agentic,
quiet=quiet,
prompt_file=prompt_file,
)
Expand Down Expand Up @@ -601,4 +625,4 @@ def update_main(
if not quiet:
rprint(f"[bold red]Error:[/bold red] {str(e)}")
# Return error result instead of sys.exit(1) to allow orchestrator to handle gracefully
return None
return None
110 changes: 95 additions & 15 deletions tests/test_update_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ def test_update_main_with_git_no_input_code(
ctx=mock_ctx,
input_prompt_file="some_prompt_file.prompt",
modified_code_file="modified_code.py",
input_code_file=None, # Not provided
input_code_file=None, # Not provided,
output=output,
use_git=git
)
Expand Down Expand Up @@ -368,30 +368,29 @@ def temp_git_repo(tmp_path, mock_get_language_for_repo):
def test_create_and_find_prompt_code_pairs(temp_git_repo):
"""
Test that the helper function correctly finds code files and creates missing prompts.
With structure preservation, prompts mirror the source directory structure.
"""
repo_path_str = str(temp_git_repo)

# Prompts for module1 and module2 should not exist yet in the prompts directory
module1_prompt_path = temp_git_repo / "prompts" / "module1_python.prompt"
module2_prompt_path = temp_git_repo / "prompts" / "module2_javascript.prompt"
existing_prompt_path = temp_git_repo / "prompts" / "existing_module_python.prompt"
assert not module1_prompt_path.exists()
assert not module2_prompt_path.exists()
# With structure preservation, prompts for files in src/ go to prompts/src/
module1_prompt_path = temp_git_repo / "prompts" / "src" / "module1_python.prompt"
module2_prompt_path = temp_git_repo / "prompts" / "src" / "module2_javascript.prompt"
existing_prompt_path_nested = temp_git_repo / "prompts" / "src" / "existing_module_python.prompt"

# Run the function
pairs = find_and_resolve_all_pairs(repo_path_str)

# Assert that missing prompts were created in the prompts directory
# Assert that prompts were created in the prompts/src directory
assert module1_prompt_path.exists()
assert module1_prompt_path.read_text() == ""
assert module2_prompt_path.exists()
assert module2_prompt_path.read_text() == ""
assert existing_prompt_path.exists()
assert existing_prompt_path.read_text() == "Existing prompt."
assert existing_prompt_path_nested.exists()
assert existing_prompt_path_nested.read_text() == ""

# Assert that the returned pairs are correct
expected_pairs = [
(str(existing_prompt_path), str(temp_git_repo / "src" / "existing_module.py")),
(str(existing_prompt_path_nested), str(temp_git_repo / "src" / "existing_module.py")),
(str(module1_prompt_path), str(temp_git_repo / "src" / "module1.py")),
(str(module2_prompt_path), str(temp_git_repo / "src" / "module2.js")),
]
Expand Down Expand Up @@ -426,9 +425,10 @@ def mock_update_logic(prompt_file, code_file, ctx, repo, simple=False):
# Check the console output for the summary table
captured = capsys.readouterr()
assert "Repository Update Summary" in captured.out
assert "prompts/module1_python.prompt" in captured.out
assert "prompts/module2_javascript.prompt" in captured.out
assert "prompts/existing_module_python.prompt" in captured.out
# With structure preservation, paths include src/
assert "prompts/src/module1_python.prompt" in captured.out
assert "prompts/src/module2_javascript.prompt" in captured.out
assert "prompts/src/existing_module_python.prompt" in captured.out
assert "Total Estimated Cost" in captured.out

assert result is not None
Expand Down Expand Up @@ -456,6 +456,7 @@ def test_update_regeneration_mode_respects_pddrc_prompts_dir(tmp_path, monkeypat
- "backend/**"
defaults:
prompts_dir: "prompts/backend"
generate_output_path: "backend"
'''
(repo_path / ".pddrc").write_text(pddrc_content)

Expand All @@ -476,10 +477,13 @@ def test_update_regeneration_mode_respects_pddrc_prompts_dir(tmp_path, monkeypat
git.Repo.init(repo_path)

# Mock update_prompt to avoid actual LLM calls
# Mock get_language to avoid PDD_PATH dependency
with patch("pdd.update_main.update_prompt") as mock_update_prompt, \
patch("pdd.update_main.get_available_agents") as mock_agents:
patch("pdd.update_main.get_available_agents") as mock_agents, \
patch("pdd.update_main.get_language") as mock_get_language:
mock_update_prompt.return_value = ("generated prompt", 0.01, "mock-model")
mock_agents.return_value = []
mock_get_language.return_value = "python"

ctx = click.Context(click.Command("update"))
ctx.obj = {"strength": 0.5, "temperature": 0.0, "verbose": False, "quiet": True}
Expand All @@ -498,6 +502,82 @@ def test_update_regeneration_mode_respects_pddrc_prompts_dir(tmp_path, monkeypat
expected_prompt_path = repo_path / "prompts" / "backend" / "some_module_python.prompt"
wrong_prompt_path = repo_path / "prompts" / "some_module_python.prompt"

assert expected_prompt_path.exists(), \
f"Expected prompt at {expected_prompt_path}, but it was not created there. " \
f"Wrong path exists: {wrong_prompt_path.exists()}"
assert not wrong_prompt_path.exists(), \
f"Prompt was created at wrong path {wrong_prompt_path} instead of {expected_prompt_path}"


def test_update_preserves_subdirectory_structure_issue_254(tmp_path, monkeypatch):
"""
Test that pdd update preserves subdirectory structure from code file path.
Regression test for GitHub issue #254.

When updating pdd/commands/generate.py with generate_output_path="pdd",
the prompt should be created at: prompts/commands/generate_python.prompt
(preserving 'commands/' subdirectory, stripping 'pdd/' package root)
"""
import git
from pathlib import Path
from unittest.mock import patch

# Setup: Create a temp directory structure
repo_path = tmp_path / "test_repo"
repo_path.mkdir()

# Create .pddrc with context that has generate_output_path
pddrc_content = '''
contexts:
pdd_cli:
paths:
- "pdd/**"
defaults:
generate_output_path: "pdd"
'''
(repo_path / ".pddrc").write_text(pddrc_content)

# Create pdd/commands/ directory and code file
pdd_dir = repo_path / "pdd"
pdd_dir.mkdir()
commands_dir = pdd_dir / "commands"
commands_dir.mkdir()
code_file = commands_dir / "generate.py"
code_file.write_text("def example(): pass")

# Change to repo directory
monkeypatch.chdir(repo_path)

# Initialize git repo
git.Repo.init(repo_path)

# Mock update_prompt to avoid actual LLM calls
# Mock get_language to avoid PDD_PATH dependency
with patch("pdd.update_main.update_prompt") as mock_update_prompt, \
patch("pdd.update_main.get_available_agents") as mock_agents, \
patch("pdd.update_main.get_language") as mock_get_language:
mock_update_prompt.return_value = ("generated prompt", 0.01, "mock-model")
mock_agents.return_value = []
mock_get_language.return_value = "python"

ctx = click.Context(click.Command("update"))
ctx.obj = {"strength": 0.5, "temperature": 0.0, "verbose": False, "quiet": True}

# Act: Call update_main in regeneration mode (only code file provided)
result = update_main(
ctx=ctx,
input_prompt_file=None, # Regeneration mode
modified_code_file=str(code_file),
input_code_file=None,
output=None,
use_git=False
)

# Assert: Prompt should preserve subdirectory structure (stripping package root)
# Expected: prompts/commands/generate_python.prompt (NOT prompts/pdd/commands/)
expected_prompt_path = repo_path / "prompts" / "commands" / "generate_python.prompt"
wrong_prompt_path = repo_path / "prompts" / "generate_python.prompt"

assert expected_prompt_path.exists(), \
f"Expected prompt at {expected_prompt_path}, but it was not created there. " \
f"Wrong path exists: {wrong_prompt_path.exists()}"
Expand Down