Skip to content

Commit 9243c91

Browse files
Updates per claude refactor PR #75
1 parent b2bcecc commit 9243c91

15 files changed

Lines changed: 521 additions & 232 deletions

src/devman/application/use_cases.py

Lines changed: 104 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,28 @@
11
# src/devman/application/use_cases.py
22
from __future__ import annotations
33

4-
import subprocess
5-
from dataclasses import dataclass
4+
from dataclasses import dataclass, field
65
from pathlib import Path
76

87
from result import Err, Ok, Result
98

10-
from devman.domain.errors import DomainError, DevmanNotFoundError
9+
from devman.constants import DEVENV_COMMAND
10+
from devman.domain.errors import DevmanNotFoundError
1111
from devman.domain.finder import DevmanFinder
1212
from devman.domain.models import DevmanDirectory, ProjectRoot, ValidationResult
13-
from devman.templates import TemplateReference, TemplateValidator
13+
from devman.domain.protocols import (
14+
CommandError,
15+
CommandExecutor,
16+
SubprocessExecutor,
17+
)
18+
from devman.domain.templates import TemplateReference, TemplateValidator
1419

1520

1621
@dataclass(frozen=True)
1722
class FindDevmanCommand:
1823
"""Command to locate .devman directory."""
1924

20-
start_path: Path | None = None
25+
start_path: Path
2126
projects_root: ProjectRoot | None = None
2227

2328

@@ -58,47 +63,22 @@ class RunDevenvResult:
5863
exit_code: int
5964

6065

61-
@dataclass(frozen=True)
62-
class RunDevenvError(DomainError):
63-
"""Error running devenv command."""
64-
65-
exit_code: int
66-
stderr: str | None = None
67-
68-
6966
class RunDevenvUseCase:
7067
"""Use case: Execute devenv command in .devman directory."""
7168

69+
def __init__(self, executor: CommandExecutor | None = None) -> None:
70+
self._executor = executor or SubprocessExecutor()
71+
7272
def execute(
7373
self, command: RunDevenvCommand
74-
) -> Result[RunDevenvResult, RunDevenvError]:
74+
) -> Result[RunDevenvResult, CommandError]:
7575
"""Execute devenv with provided arguments."""
76-
try:
77-
result = subprocess.run(
78-
["devenv", *command.devenv_args],
79-
cwd=command.devman_directory.path,
80-
check=True,
81-
capture_output=True,
82-
text=True,
83-
)
84-
return Ok(RunDevenvResult(exit_code=result.returncode))
85-
86-
except subprocess.CalledProcessError as e:
87-
return Err(
88-
RunDevenvError(
89-
message=f"devenv failed with exit code {e.returncode}",
90-
exit_code=e.returncode,
91-
stderr=e.stderr if e.stderr else None,
92-
)
93-
)
76+
result = self._executor.execute(
77+
args=[DEVENV_COMMAND, *command.devenv_args],
78+
cwd=command.devman_directory.path,
79+
)
9480

95-
except FileNotFoundError:
96-
return Err(
97-
RunDevenvError(
98-
message="devenv command not found",
99-
exit_code=127,
100-
)
101-
)
81+
return result.map(lambda r: RunDevenvResult(exit_code=r.exit_code))
10282

10383

10484
@dataclass(frozen=True)
@@ -124,5 +104,89 @@ class ValidateTemplateUseCase:
124104

125105
def execute(self, command: ValidateTemplateCommand) -> ValidateTemplateResult:
126106
"""Execute validation."""
127-
validation_result = TemplateValidator.validate_typed(command.template_reference)
107+
validation_result = TemplateValidator.validate_reference(
108+
command.template_reference
109+
)
128110
return ValidateTemplateResult(validation_result=validation_result)
111+
112+
113+
@dataclass(frozen=True)
114+
class CreateProjectCommand:
115+
"""Command to create a new project from a template."""
116+
117+
template_source: str
118+
destination: Path
119+
data: dict[str, str] = field(default_factory=dict)
120+
validate: bool = True
121+
122+
123+
@dataclass(frozen=True)
124+
class CreateProjectResult:
125+
"""Result of project creation."""
126+
127+
destination: Path
128+
validation_result: ValidationResult | None = None
129+
130+
131+
@dataclass(frozen=True)
132+
class CreateProjectError:
133+
"""Error during project creation."""
134+
135+
message: str
136+
validation_result: ValidationResult | None = None
137+
138+
139+
class CreateProjectUseCase:
140+
"""Use case: Create a new project from a copier template."""
141+
142+
def execute(
143+
self, command: CreateProjectCommand
144+
) -> Result[CreateProjectResult, CreateProjectError]:
145+
"""Execute project creation."""
146+
# Parse template reference
147+
try:
148+
template_ref = TemplateReference.from_string(command.template_source)
149+
except ValueError as e:
150+
return Err(
151+
CreateProjectError(message=f"Invalid template source: {e}")
152+
)
153+
154+
# Validate if requested
155+
validation_result: ValidationResult | None = None
156+
if command.validate:
157+
validation_result = TemplateValidator.validate_reference(template_ref)
158+
159+
if not validation_result.is_valid:
160+
return Err(
161+
CreateProjectError(
162+
message="Template validation failed",
163+
validation_result=validation_result,
164+
)
165+
)
166+
167+
# Resolve source path
168+
source = template_ref.location
169+
if template_ref.source_type == "file":
170+
source = str(template_ref.resolve_path())
171+
172+
# Run copier
173+
try:
174+
from copier import run_copy
175+
176+
run_copy(
177+
src_path=source,
178+
dst_path=str(command.destination),
179+
data=command.data if command.data else None,
180+
unsafe=True,
181+
)
182+
except Exception as e:
183+
return Err(
184+
CreateProjectError(message=f"Failed to create project: {e}")
185+
)
186+
187+
return Ok(
188+
CreateProjectResult(
189+
destination=command.destination,
190+
validation_result=validation_result,
191+
)
192+
)

src/devman/cli.py

Lines changed: 42 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -6,55 +6,18 @@
66
from pathlib import Path
77

88
import typer
9-
from copier import run_copy
109

1110
from devman import __version__
1211
from devman.application.use_cases import (
12+
CreateProjectCommand,
13+
CreateProjectUseCase,
1314
FindDevmanCommand,
1415
FindDevmanUseCase,
1516
RunDevenvCommand,
1617
RunDevenvUseCase,
17-
ValidateTemplateCommand,
18-
ValidateTemplateUseCase,
1918
)
20-
from devman.config import load_config
19+
from devman.config import ConfigRepository, load_config
2120
from devman.domain.models import ProjectRoot
22-
from devman.templates import TemplateReference
23-
24-
25-
# Re-export domain DevmanFinder for backward compatibility
26-
from devman.domain.finder import DevmanFinder as _DomainFinder # noqa: F401
27-
28-
29-
class DevmanFinder:
30-
"""Locate the nearest devman configuration directory.
31-
32-
Legacy wrapper for backward compatibility.
33-
Prefer using devman.domain.finder.DevmanFinder directly.
34-
"""
35-
36-
def __init__(self, projects_root: Path | None = None) -> None:
37-
self.projects_root = projects_root
38-
39-
@classmethod
40-
def from_config(cls) -> DevmanFinder:
41-
config = load_config()
42-
return cls(projects_root=config.projects_root)
43-
44-
def find(self, start_path: Path | None = None) -> Path | None:
45-
"""Find .devman directory, returning Path or None for backward compat."""
46-
root = None
47-
if self.projects_root is not None:
48-
root_result = ProjectRoot.create(self.projects_root)
49-
if root_result.is_ok():
50-
root = root_result.unwrap()
51-
52-
domain_finder = _DomainFinder(projects_root=root)
53-
result = domain_finder.find(start_path=start_path)
54-
55-
if result.is_ok():
56-
return result.unwrap().path
57-
return None
5821

5922

6023
app = typer.Typer()
@@ -86,7 +49,7 @@ def run(
8649

8750
# Execute find use case
8851
find_use_case = FindDevmanUseCase()
89-
find_command = FindDevmanCommand(projects_root=root)
52+
find_command = FindDevmanCommand(start_path=Path.cwd(), projects_root=root)
9053
find_result = find_use_case.execute(find_command)
9154

9255
if find_result.is_err():
@@ -125,66 +88,52 @@ def new(
12588
),
12689
) -> None:
12790
"""Create a new project from a copier template."""
128-
# Parse template reference
129-
try:
130-
template_ref = TemplateReference.from_string(template_source)
131-
except ValueError as e:
132-
typer.echo(f"Invalid template source: {e}", err=True)
133-
raise typer.Exit(1)
134-
135-
# Validate if requested
136-
if validate:
137-
typer.echo("Validating template...")
138-
139-
validate_use_case = ValidateTemplateUseCase()
140-
validate_command = ValidateTemplateCommand(template_reference=template_ref)
141-
validate_result = validate_use_case.execute(validate_command)
142-
143-
vr = validate_result.validation_result
144-
145-
if not vr.is_valid:
146-
typer.echo("Template validation errors:", err=True)
147-
for error in vr.errors:
148-
loc = f" ({error.location})" if error.location else ""
149-
typer.echo(f" - {error.message}{loc}", err=True)
150-
raise typer.Exit(1)
151-
152-
if vr.warnings:
153-
typer.echo("Template warnings:")
154-
for warning in vr.warnings:
155-
loc = f" ({warning.location})" if warning.location else ""
156-
typer.echo(f" - {warning.message}{loc}")
157-
15891
# Parse data overrides
159-
data_dict = {}
92+
data_dict: dict[str, str] = {}
16093
for item in data:
16194
if "=" not in item:
16295
typer.echo(f"Invalid data format: {item} (expected key=value)", err=True)
16396
raise typer.Exit(1)
16497
key, value = item.split("=", 1)
16598
data_dict[key] = value
16699

167-
# Run copier
168-
try:
169-
typer.echo(f"Creating project at {destination}...")
100+
# Execute use case
101+
use_case = CreateProjectUseCase()
102+
command = CreateProjectCommand(
103+
template_source=template_source,
104+
destination=destination,
105+
data=data_dict,
106+
validate=validate,
107+
)
170108

171-
source = template_ref.location
172-
if template_ref.source_type == "file":
173-
source = str(template_ref.resolve_path())
109+
typer.echo(f"Creating project at {destination}...")
110+
result = use_case.execute(command)
174111

175-
run_copy(
176-
src_path=source,
177-
dst_path=str(destination),
178-
data=data_dict if data_dict else None,
179-
unsafe=True,
180-
)
112+
if result.is_err():
113+
error = result.unwrap_err()
181114

182-
typer.echo(f"Project created successfully at {destination}")
115+
# Show validation errors if present
116+
if error.validation_result and not error.validation_result.is_valid:
117+
typer.echo("Template validation errors:", err=True)
118+
for issue in error.validation_result.errors:
119+
loc = f" ({issue.location})" if issue.location else ""
120+
typer.echo(f" - {issue.message}{loc}", err=True)
121+
else:
122+
typer.echo(error.message, err=True)
183123

184-
except Exception as e:
185-
typer.echo(f"Failed to create project: {e}", err=True)
186124
raise typer.Exit(1)
187125

126+
success = result.unwrap()
127+
128+
# Show warnings if any
129+
if success.validation_result and success.validation_result.warnings:
130+
typer.echo("Template warnings:")
131+
for warning in success.validation_result.warnings:
132+
loc = f" ({warning.location})" if warning.location else ""
133+
typer.echo(f" - {warning.message}{loc}")
134+
135+
typer.echo(f"Project created successfully at {destination}")
136+
188137

189138
@app.command()
190139
def config(
@@ -199,23 +148,20 @@ def config(
199148
typer.echo("No configuration changes provided.", err=True)
200149
raise typer.Exit(1)
201150

151+
repo = ConfigRepository()
152+
202153
if show:
203-
current_config = load_config()
154+
current_config = repo.load()
204155
typer.echo("Current configuration:")
205156
if current_config.projects_root is None:
206157
typer.echo(" projects_root: (not set)")
207158
else:
208159
typer.echo(f" projects_root: {current_config.projects_root}")
209160

210161
if projects_root is not None:
211-
config_path = Path("~/.config/devman/config.env").expanduser()
212-
config_path.parent.mkdir(parents=True, exist_ok=True)
213-
resolved_root = projects_root.expanduser().resolve()
214-
config_path.write_text(
215-
f"DEVMAN_PROJECTS_ROOT={resolved_root}\n",
216-
encoding="utf-8",
217-
)
218-
typer.echo(f"Updated projects root to {resolved_root}.")
162+
repo.save_projects_root(projects_root)
163+
resolved = projects_root.expanduser().resolve()
164+
typer.echo(f"Updated projects root to {resolved}.")
219165

220166

221167
@app.command()

0 commit comments

Comments
 (0)