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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Available commands:
- `create` - Create a new project with guided scoring
- `list` (alias: `ls`) - List all projects with scores, optionally sorted
- `view <project-name>` - View detailed project information
- `update <project-name>` - Change a project's description or scores
- `delete <project-name>` (alias: `rm`) - Delete a project by name
- `export` - Export all projects to Markdown format, optionally sorted
- `help` - Show available commands
Expand Down Expand Up @@ -71,6 +72,18 @@ list -s impact

Supported `--sort` values are `name`, `impact`, `confidence`, `ease`, `reach`, `effort`, `ice` and `rice`. Every value except `name` sorts from highest to lowest; omitting `--sort` keeps the order in which projects were created.

### Update Examples
```bash
# Change a project's description
update "My Project" --description "A sharper pitch"

# Change one or more scores (1-5); fields not given keep their current values
update "My Project" --impact 5 --effort 2
update "My Project" -c 4 -r 3
```

`update` accepts `--description` (`-d`), `--impact` (`-i`), `--confidence` (`-c`), `--ease` (`-e`), `--reach` (`-r`) and `--effort` (`-f`), and at least one of them must be given. The change is saved to `projects.json` immediately and the project keeps its place in creation order; the new ICE and RICE scores are printed. If no project has the given name, `update` reports `Project not found: <project-name>`; an empty description or a score outside 1-5 is rejected and nothing is changed. A project cannot be renamed with `update`.

### Delete Examples
```bash
# Delete a project by name
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.preponderous.parpt.command;

import com.preponderous.parpt.domain.Project;
import com.preponderous.parpt.repo.ProjectRepository;
import com.preponderous.parpt.score.ScoreCalculator;
import com.preponderous.parpt.service.ProjectService;
import org.springframework.shell.standard.ShellComponent;
import org.springframework.shell.standard.ShellMethod;
import org.springframework.shell.standard.ShellOption;

import java.util.stream.Stream;

@ShellComponent
public class UpdateProjectCommand {

private final ProjectService projectService;
private final ScoreCalculator scoreCalculator;

public UpdateProjectCommand(ProjectService projectService, ScoreCalculator scoreCalculator) {
this.projectService = projectService;
this.scoreCalculator = scoreCalculator;
}

private static boolean isOutOfRange(Integer score) {
return score != null && (score < 1 || score > 5);
}

@ShellMethod(key = "update", value = "Updates the description or scores of a specific project by name.")
public String execute(
@ShellOption(help = "The name of the project to update") String projectName,
@ShellOption(value = {"-d", "--description"}, help = "New description of the project", defaultValue = ShellOption.NULL) String description,
@ShellOption(value = {"-i", "--impact"}, help = "New impact score (1-5)", defaultValue = ShellOption.NULL) Integer impact,
@ShellOption(value = {"-c", "--confidence"}, help = "New confidence score (1-5)", defaultValue = ShellOption.NULL) Integer confidence,
@ShellOption(value = {"-e", "--ease"}, help = "New ease score (1-5)", defaultValue = ShellOption.NULL) Integer ease,
@ShellOption(value = {"-r", "--reach"}, help = "New reach score (1-5)", defaultValue = ShellOption.NULL) Integer reach,
@ShellOption(value = {"-f", "--effort"}, help = "New effort score (1-5)", defaultValue = ShellOption.NULL) Integer effort
) {
if (description == null && Stream.of(impact, confidence, ease, reach, effort).allMatch(score -> score == null)) {
return "Nothing to update. Provide at least one of --description, --impact, --confidence, --ease, --reach or --effort.";
}
if (description != null && description.isEmpty()) {
return "Project description cannot be empty.";
}
if (Stream.of(impact, confidence, ease, reach, effort).anyMatch(UpdateProjectCommand::isOutOfRange)) {
return "All scores must be between 1 and 5.";
}

Project project;
try {
project = projectService.updateProject(projectName, description, impact, confidence, ease, reach, effort);
} catch (ProjectRepository.ProjectNotFoundException e) {
return "Project not found: " + projectName;
}

double iceScore = scoreCalculator.ice(project);
double riceScore = scoreCalculator.rice(project);
return String.format("Project updated: %s\nICE Score: %.2f\nRICE Score: %.2f", projectName, iceScore, riceScore);
}
}
10 changes: 10 additions & 0 deletions src/main/java/com/preponderous/parpt/repo/ProjectRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ public void remove(String projectName) throws ProjectNotFoundException {
projectJsonReaderWriter.writeJson(projects);
}

/**
* Replaces the stored project that has the same name as the given one, keeping its position
* in creation order.
*/
public void update(Project project) throws ProjectNotFoundException {
int index = projects.indexOf(findByName(project.getName()));
projects.set(index, project);
projectJsonReaderWriter.writeJson(projects);
}

public Project findByName(String projectName) throws ProjectNotFoundException {
Project retrievedProject = projects.stream()
.filter(project -> project.getName().equals(projectName))
Expand Down
19 changes: 19 additions & 0 deletions src/main/java/com/preponderous/parpt/service/ProjectService.java
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,25 @@ public Project getProject(String projectName) throws ProjectRepository.ProjectNo
return projectRepository.findByName(projectName);
}

/**
* Updates the named project, changing only the fields given; a null argument keeps the
* project's current value.
*/
public Project updateProject(String name, String description, Integer impact, Integer confidence, Integer ease, Integer reach, Integer effort) throws ProjectRepository.ProjectNotFoundException {
Project existing = projectRepository.findByName(name);
Project updated = Project.builder()
.name(existing.getName())
.description(description != null ? description : existing.getDescription())
.impact(impact != null ? impact : existing.getImpact())
.confidence(confidence != null ? confidence : existing.getConfidence())
.ease(ease != null ? ease : existing.getEase())
.reach(reach != null ? reach : existing.getReach())
.effort(effort != null ? effort : existing.getEffort())
.build();
projectRepository.update(updated);
return updated;
}

public void deleteProject(String projectName) throws ProjectRepository.ProjectNotFoundException {
projectRepository.remove(projectName);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package com.preponderous.parpt.command;

import com.preponderous.parpt.domain.Project;
import com.preponderous.parpt.repo.ProjectJsonReaderWriter;
import com.preponderous.parpt.repo.ProjectRepository;
import com.preponderous.parpt.score.ScoreCalculator;
import com.preponderous.parpt.service.ProjectService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;

@SpringBootTest
class UpdateProjectCommandTest {

UpdateProjectCommand updateProjectCommand;

@Autowired
ProjectService projectService;

@Autowired
ProjectRepository projectRepository;

@Autowired
ProjectJsonReaderWriter projectJsonReaderWriter;

@Autowired
ScoreCalculator scoreCalculator;

@BeforeEach
void setUp() {
// Initialize the command with the project service and score calculator
updateProjectCommand = new UpdateProjectCommand(projectService, scoreCalculator);

// Clear any existing projects in the repository before each test
projectRepository.clear();
}

@Test
void shouldReturnProjectNotFoundWhenProjectDoesNotExist() {
// Given no projects exist

// When the command is executed with a non-existing project name
var result = updateProjectCommand.execute("NonExistingProject", "New description", null, null, null, null, null);

// Then the result should indicate that the project was not found
assertEquals("Project not found: NonExistingProject", result);
}

@Test
void shouldUpdateOnlyTheGivenFields() throws Exception {
// Given a project exists
projectService.createProject("Test Project", "Original description", 1, 2, 3, 4, 5);

// When the command changes the description and two of the scores
var result = updateProjectCommand.execute("Test Project", "Updated description", 5, null, null, 1, null);

// Then the given fields should change and the rest should keep their values
Project project = projectService.getProject("Test Project");
assertEquals("Updated description", project.getDescription());
assertEquals(5, project.getImpact());
assertEquals(2, project.getConfidence());
assertEquals(3, project.getEase());
assertEquals(1, project.getReach());
assertEquals(5, project.getEffort());
assertEquals(String.format("Project updated: Test Project\nICE Score: %.2f\nRICE Score: %.2f",
scoreCalculator.ice(project), scoreCalculator.rice(project)), result);
}

@Test
void shouldKeepProjectPositionAmongOtherProjects() throws Exception {
// Given three projects exist
projectService.createProject("First", "One", 1, 1, 1, 1, 1);
projectService.createProject("Second", "Two", 2, 2, 2, 2, 2);
projectService.createProject("Third", "Three", 3, 3, 3, 3, 3);

// When the middle project is updated
updateProjectCommand.execute("Second", null, null, null, null, null, 5);

// Then the projects should stay in creation order and only the middle one should change
List<Project> projects = projectService.getProjects();
assertEquals(List.of("First", "Second", "Third"), projects.stream().map(Project::getName).toList());
assertEquals(5, projects.get(1).getEffort());
assertEquals(1, projects.get(0).getEffort());
assertEquals(3, projects.get(2).getEffort());
}

@Test
void shouldPersistUpdateToStorage() throws Exception {
// Given a project has been saved to storage
projectService.createProject("Persisted Project", "Saved to disk", 1, 2, 3, 4, 5);

// When the project is updated
updateProjectCommand.execute("Persisted Project", "Changed on disk", null, null, 4, null, null);

// Then re-reading storage should contain the updated values
List<Project> stored = projectJsonReaderWriter.readJson();
assertEquals(1, stored.size());
assertEquals("Changed on disk", stored.get(0).getDescription());
assertEquals(4, stored.get(0).getEase());
}

@Test
void shouldRejectUpdateWithNothingToChange() throws Exception {
// Given a project exists
projectService.createProject("Test Project", "Original description", 1, 2, 3, 4, 5);

// When the command is executed without any field to change
var result = updateProjectCommand.execute("Test Project", null, null, null, null, null, null);

// Then the result should explain what to provide and the project should be unchanged
assertEquals("Nothing to update. Provide at least one of --description, --impact, --confidence, --ease, --reach or --effort.", result);
assertEquals("Original description", projectService.getProject("Test Project").getDescription());
}

@Test
void shouldRejectEmptyDescription() throws Exception {
// Given a project exists
projectService.createProject("Test Project", "Original description", 1, 2, 3, 4, 5);

// When the command is executed with an empty description
var result = updateProjectCommand.execute("Test Project", "", null, null, null, null, null);

// Then the update should be rejected and the description kept
assertEquals("Project description cannot be empty.", result);
assertEquals("Original description", projectService.getProject("Test Project").getDescription());
}

@Test
void shouldRejectOutOfRangeScoresWithoutChangingAnything() throws Exception {
// Given a project exists
projectService.createProject("Test Project", "Original description", 1, 2, 3, 4, 5);

// When the command is executed with one valid and one out-of-range score
var tooHigh = updateProjectCommand.execute("Test Project", null, 4, null, null, null, 6);
var tooLow = updateProjectCommand.execute("Test Project", null, null, 0, null, null, null);

// Then both updates should be rejected and no score should change
assertEquals("All scores must be between 1 and 5.", tooHigh);
assertEquals("All scores must be between 1 and 5.", tooLow);
Project project = projectService.getProject("Test Project");
assertEquals(1, project.getImpact());
assertEquals(2, project.getConfidence());
assertEquals(5, project.getEffort());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,33 @@ void remove_WithUnknownName_ShouldThrowAndNotPersist() {
verify(projectJsonReaderWriter, never()).writeJson(anyList());
}

@Test
void update_ShouldReplaceProjectInPlaceAndPersistOnce() throws Exception {
repository.add(beta);
writtenNames.clear();
clearInvocations(projectJsonReaderWriter);
Project changedAlpha = Project.builder().name("Alpha").description("Changed").impact(5).confidence(5).ease(5).reach(5).effort(5).build();

repository.update(changedAlpha);

assertThat(repository.findAll()).containsExactly(changedAlpha, beta);
assertThat(repository.findByName("Alpha")).isSameAs(changedAlpha);
verify(projectJsonReaderWriter, times(1)).writeJson(anyList());
assertThat(writtenNames).containsExactly(List.of("Alpha", "Beta"));
}

@Test
void update_WithUnknownName_ShouldThrowAndNotPersist() {
Project missing = Project.builder().name("Missing").description("Nowhere").build();

ProjectRepository.ProjectNotFoundException exception = assertThrows(
ProjectRepository.ProjectNotFoundException.class, () -> repository.update(missing));

assertThat(exception.getMessage()).isEqualTo("Project not found: Missing");
assertThat(repository.findAll()).containsExactly(alpha);
verify(projectJsonReaderWriter, never()).writeJson(anyList());
}

@Test
void clear_ShouldEmptyProjectsAndPersistOnce() {
repository.clear();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,51 @@ void getProject_WithUnknownName_ShouldPropagateNotFound() throws Exception {
assertThat(exception.getMessage()).isEqualTo("Project not found: Missing");
}

@Test
void updateProject_ShouldChangeOnlyGivenFieldsAndPersistViaRepository() throws Exception {
Project alpha = Project.builder().name("Alpha").description("First").impact(1).confidence(2).ease(3).reach(4).effort(5).build();
when(projectRepository.findByName("Alpha")).thenReturn(alpha);

Project updated = service.updateProject("Alpha", "Changed", null, 4, null, 1, null);

ArgumentCaptor<Project> captor = ArgumentCaptor.forClass(Project.class);
verify(projectRepository, times(1)).update(captor.capture());
assertThat(updated).isSameAs(captor.getValue());
assertThat(updated.getName()).isEqualTo("Alpha");
assertThat(updated.getDescription()).isEqualTo("Changed");
assertThat(updated.getImpact()).isEqualTo(1);
assertThat(updated.getConfidence()).isEqualTo(4);
assertThat(updated.getEase()).isEqualTo(3);
assertThat(updated.getReach()).isEqualTo(1);
assertThat(updated.getEffort()).isEqualTo(5);
verifyNoInteractions(usageReporter);
}

@Test
void updateProject_WithAllFieldsGiven_ShouldApplyEachToItsOwnField() throws Exception {
when(projectRepository.findByName("Alpha")).thenReturn(Project.builder().name("Alpha").description("First").build());

Project updated = service.updateProject("Alpha", "Changed", 5, 4, 3, 2, 1);

// Distinct score values catch any argument transposed between the signature and the builder
assertThat(updated.getImpact()).isEqualTo(5);
assertThat(updated.getConfidence()).isEqualTo(4);
assertThat(updated.getEase()).isEqualTo(3);
assertThat(updated.getReach()).isEqualTo(2);
assertThat(updated.getEffort()).isEqualTo(1);
}

@Test
void updateProject_WithUnknownName_ShouldPropagateNotFoundAndNotUpdate() throws Exception {
when(projectRepository.findByName("Missing"))
.thenThrow(new ProjectRepository.ProjectNotFoundException("Project not found: Missing"));

assertThrows(ProjectRepository.ProjectNotFoundException.class,
() -> service.updateProject("Missing", "Changed", 1, 1, 1, 1, 1));

verify(projectRepository, never()).update(any(Project.class));
}

@Test
void deleteProject_ShouldRemoveByNameAndNotReportUsage() throws Exception {
service.deleteProject("Alpha");
Expand Down
Loading