Implement a multi-level digital recipe manager
Company: Ramp
Role: Software Engineer
Category: Coding & Algorithms
Difficulty: medium
Interview Round: Take-home Project
Implement a `RecipeManager` class for a digital recipe manager. The task is progressive (Levels 1–4). You can assume a single-process in-memory implementation. Correctness matters; efficiency is not required. Unless otherwise stated, all string comparisons for recipe names and ingredient matching are **case-insensitive**.
### Data model
A recipe has:
- `recipe_id`: formatted as `"recipe" + N`, where `N` is a sequential integer starting from 1 in creation order.
- `name`: unique across all recipes (case-insensitive).
- `ingredients`: list of strings (keep original order).
- `steps`: list of strings (keep original order).
When returning a recipe, represent it as:
- `[name, ingredients_as_string, steps_as_string]`
- `ingredients_as_string` is the ingredients joined by `","` (comma separator), and similarly for `steps_as_string`.
If a method returns a list of recipe IDs, they are strings like `"recipe1"`, `"recipe2"`, etc.
---
## Level 1: Basic CRUD (no users)
Implement:
1) `add_recipe(self, name: str, ingredients: list[str], steps: list[str]) -> str | None`
- Add a new recipe and return its `recipe_id`.
- If a recipe with the same name already exists (case-insensitive), return `None`.
2) `get_recipe(self, recipe_id: str) -> list[str]`
- Return `[name, ingredients_as_string, steps_as_string]` for the recipe.
- If the recipe does not exist, return an empty array `[]`.
- Ingredients must be returned in the same order they were added.
3) `update_recipe(self, recipe_id: str, name: str, ingredients: list[str], steps: list[str]) -> bool`
- Update the recipe fields.
- Return `True` if successful.
- Return `False` if the recipe does not exist, or if the new name conflicts with another existing recipe name (case-insensitive).
4) `delete_recipe(self, recipe_id: str) -> bool`
- Delete the recipe.
- Return `True` if it existed and was deleted; otherwise `False`.
---
## Level 2: Searching and sorting
Implement:
5) `search_recipes_by_ingredient(self, ingredient: str) -> list[str]`
- Return recipe IDs for recipes that contain the specified ingredient (case-insensitive match).
- Sort results by:
1. number of ingredients ascending
2. `recipe_id` ascending (tie-breaker)
6) `list_recipes(self, sort_by: str) -> list[str]`
- Return all recipe IDs sorted by `sort_by`, where `sort_by` is either:
- `"name"`: lexicographic ascending by recipe name; tie-breaker `recipe_id` ascending
- `"ingredient_count"`: ascending by number of ingredients; tie-breaker `recipe_id` ascending
- If `sort_by` is invalid, default to sorting by `"name"`.
---
## Level 3: Users and edits
Introduce users.
7) `add_user(self, user_id: str) -> bool`
- Add a new user.
- Return `True` if added; `False` if the user already exists.
8) `edit_recipe(self, user_id: str, recipe_id: str, new_name: str, new_ingredients: list[str], new_steps: list[str]) -> bool`
- Any existing user may edit any recipe.
- Enforce recipe-name uniqueness (case-insensitive).
- Return `True` on success.
- Return `False` if the user does not exist, the recipe does not exist, or the new name conflicts with another recipe name.
---
## Level 4: Version history and rollback
Add version control for recipes.
Definitions:
- Recipes created by `add_recipe` have **no version history** until the first successful `edit_recipe` or `update_recipe`.
- Every successful `edit_recipe` or `update_recipe` creates a new version entry.
- Version entries are numbered starting at 1 and increase by 1 per entry.
- Each version string format:
- `<version>:<name>:<ingredients_as_string>:<steps_as_string>:<last_edited_by>`
- For `edit_recipe`, `<last_edited_by>` is the `user_id`.
- For `update_recipe`, `<last_edited_by>` is the literal string `"system"`.
Implement:
9) `version_recipe(self, recipe_id: str) -> list[str]`
- Return all versions for the recipe, sorted by version number ascending.
- Return `[]` if the recipe does not exist or if it was never edited/updated (i.e., no version history).
10) `rollback_recipe(self, recipe_id: str, version: int) -> bool`
- Roll back the current recipe state (name/ingredients/steps) to match the specified historical `version`.
- Rolling back creates a **new** version entry appended to the history (history is immutable), with editor set to the literal string `"rollback"`.
- Return `True` if successful.
- Return `False` if the recipe does not exist, the requested version does not exist, or if rolling back would cause a name conflict with another existing recipe (case-insensitive).
Quick Answer: Tests skills in data modeling and stateful class implementation, covering CRUD operations, case-insensitive string handling, recipe ID generation, list management, search and sort logic, and basic user/edit permissions.
Part 1: Simulate Basic Recipe CRUD
Write `solution(operations)` to simulate a simple in-memory recipe manager.
Each operation is one of:
- `['add_recipe', name, ingredients, steps]`
- `['get_recipe', recipe_id]`
- `['update_recipe', recipe_id, name, ingredients, steps]`
- `['delete_recipe', recipe_id]`
Rules:
- A successful add creates IDs `recipe1`, `recipe2`, ... in order of successful creation.
- Recipe names must be unique case-insensitively.
- `get_recipe` returns `[name, ingredients_as_string, steps_as_string]`, where each list is joined by `','`.
- Preserve ingredient and step order exactly.
- If a recipe does not exist, `get_recipe` returns `[]`.
- Return a list containing the result of every operation, in order.
Constraints
- 0 <= len(operations) <= 1000
- 0 <= len(ingredients), len(steps) <= 100 per operation
- Recipe names are unique case-insensitively among existing recipes
- Recipe IDs in queries may refer to missing recipes
Examples
Input: [['add_recipe', 'Pasta', ['noodles', 'salt'], ['boil', 'mix']], ['get_recipe', 'recipe1'], ['update_recipe', 'recipe1', 'Cream Pasta', ['noodles', 'cream'], ['boil', 'stir']], ['get_recipe', 'recipe1'], ['delete_recipe', 'recipe1'], ['get_recipe', 'recipe1']]
Expected Output: ['recipe1', ['Pasta', 'noodles,salt', 'boil,mix'], True, ['Cream Pasta', 'noodles,cream', 'boil,stir'], True, []]
Explanation: Basic add, read, update, and delete flow.
Input: [['add_recipe', 'Soup', ['water'], ['boil']], ['add_recipe', 'soup', ['water', 'salt'], ['boil']], ['add_recipe', 'Salad', ['lettuce'], ['mix']], ['update_recipe', 'recipe2', 'SOUP', ['lettuce'], ['mix']], ['get_recipe', 'recipe2']]
Expected Output: ['recipe1', None, 'recipe2', False, ['Salad', 'lettuce', 'mix']]
Explanation: Recipe names are unique case-insensitively, both when adding and updating.
Hints
- Store recipes by `recipe_id`, and keep a second map from normalized recipe name to `recipe_id`.
- For uniqueness, compare names with `casefold()` rather than the original casing.
Part 2: Search and Sort Recipes
Write `solution(operations)` to simulate a recipe manager focused on searching and sorting.
Each operation is one of:
- `['add_recipe', name, ingredients, steps]`
- `['search_recipes_by_ingredient', ingredient]`
- `['list_recipes', sort_by]`
Rules:
- Successful adds create IDs `recipe1`, `recipe2`, ... in order of successful creation.
- Recipe names must be unique case-insensitively.
- Ingredient matching is exact but case-insensitive.
- `search_recipes_by_ingredient` returns recipe IDs sorted by:
1. number of ingredients ascending
2. recipe ID ascending
- `list_recipes(sort_by)` returns all recipe IDs sorted by:
- `'name'`: recipe name ascending case-insensitively, then recipe ID ascending
- `'ingredient_count'`: ingredient count ascending, then recipe ID ascending
- If `sort_by` is invalid, default to `'name'`.
- Return a list containing the result of every operation, in order.
Constraints
- 0 <= len(operations) <= 1000
- 0 <= len(ingredients), len(steps) <= 100 per recipe
- Recipe IDs should be ordered by their numeric suffix for tie-breaking
- If there are no recipes, search/list should return an empty list
Examples
Input: [['add_recipe', 'Pancakes', ['flour', 'eggs', 'milk'], ['mix', 'cook']], ['add_recipe', 'Omelette', ['eggs', 'butter'], ['whisk', 'cook']], ['add_recipe', 'FrenchToast', ['bread', 'eggs', 'milk'], ['dip', 'fry']], ['search_recipes_by_ingredient', 'EGGS'], ['list_recipes', 'name']]
Expected Output: ['recipe1', 'recipe2', 'recipe3', ['recipe2', 'recipe1', 'recipe3'], ['recipe3', 'recipe2', 'recipe1']]
Explanation: Search sorts by ingredient count, then by recipe ID. Listing by name is case-insensitive.
Input: [['add_recipe', 'Soup', ['water'], ['boil']], ['add_recipe', 'soup', ['water', 'salt'], ['boil']], ['add_recipe', 'Salad', ['lettuce'], ['mix']], ['list_recipes', 'unknown'], ['search_recipes_by_ingredient', 'tomato']]
Expected Output: ['recipe1', None, 'recipe2', ['recipe2', 'recipe1'], []]
Explanation: Invalid sort key defaults to name sorting, and duplicate names are rejected.
Hints
- Keep the full recipe data so you can sort by either name or ingredient count later.
- When sorting by recipe ID, compare the numeric part after `'recipe'`, not the raw string.
Part 3: Recipe Users and Edits
Write `solution(operations)` to simulate a recipe manager with users.
Each operation is one of:
- `['add_recipe', name, ingredients, steps]`
- `['get_recipe', recipe_id]`
- `['add_user', user_id]`
- `['edit_recipe', user_id, recipe_id, new_name, new_ingredients, new_steps]`
Rules:
- Successful recipe adds create IDs `recipe1`, `recipe2`, ... in order of successful creation.
- Recipe names must remain unique case-insensitively.
- Any existing user may edit any existing recipe.
- `edit_recipe` fails if the user does not exist, the recipe does not exist, or the new name conflicts with another recipe.
- `get_recipe` returns `[name, ingredients_as_string, steps_as_string]`, with lists joined by `','`.
- Return a list containing the result of every operation, in order.
Constraints
- 0 <= len(operations) <= 1000
- 0 <= len(ingredients), len(steps) <= 100 per recipe
- User IDs are case-sensitive and must be unique exactly
- Editing a recipe to the same name with different casing is allowed if it is the same recipe
Examples
Input: [['add_recipe', 'Toast', ['bread'], ['toast']], ['add_user', 'u1'], ['edit_recipe', 'u1', 'recipe1', 'Buttered Toast', ['bread', 'butter'], ['toast', 'spread']], ['get_recipe', 'recipe1']]
Expected Output: ['recipe1', True, True, ['Buttered Toast', 'bread,butter', 'toast,spread']]
Explanation: A valid user can edit an existing recipe.
Input: [['add_recipe', 'Soup', ['water'], ['boil']], ['add_user', 'chef'], ['add_user', 'chef'], ['edit_recipe', 'ghost', 'recipe1', 'Hot Soup', ['water'], ['boil']], ['edit_recipe', 'chef', 'recipe2', 'Hot Soup', ['water'], ['boil']], ['get_recipe', 'recipe1']]
Expected Output: ['recipe1', True, False, False, False, ['Soup', 'water', 'boil']]
Explanation: Duplicate users are rejected, and missing user/recipe edits fail.
Hints
- Use one set for users and one map from normalized recipe name to recipe ID.
- When editing, a name is only a conflict if it belongs to a different recipe.
Part 4: Recipe Version History and Rollback
Write `solution(operations)` to simulate a recipe manager with version history.
Each operation is one of:
- `['add_recipe', name, ingredients, steps]`
- `['get_recipe', recipe_id]`
- `['update_recipe', recipe_id, name, ingredients, steps]`
- `['add_user', user_id]`
- `['edit_recipe', user_id, recipe_id, new_name, new_ingredients, new_steps]`
- `['version_recipe', recipe_id]`
- `['rollback_recipe', recipe_id, version_number]`
Rules:
- Successful recipe adds create IDs `recipe1`, `recipe2`, ... in order of successful creation.
- Recipe names must be unique case-insensitively.
- A newly added recipe has no version history yet.
- Every successful `update_recipe` creates a new version whose editor is `'system'`.
- Every successful `edit_recipe` creates a new version whose editor is the given `user_id`.
- Versions are snapshots of the recipe state after the successful update/edit.
- `version_recipe` returns all version strings in ascending order:
`<version>:<name>:<ingredients_as_string>:<steps_as_string>:<last_edited_by>`
- `rollback_recipe(recipe_id, version)` changes the current recipe to match that historical version and appends a brand-new version with editor `'rollback'`.
- Rollback fails if the recipe does not exist, the version does not exist, or the rollback name would conflict with another current recipe.
- Return a list containing the result of every operation, in order.
Constraints
- 0 <= len(operations) <= 1000
- 0 <= len(ingredients), len(steps) <= 100 per recipe state
- Version numbers start at 1 for each recipe and increase by 1 per successful update/edit/rollback snapshot
- A recipe with no successful update/edit has no version history
Examples
Input: [['add_recipe', 'Soup', ['water'], ['boil']], ['add_user', 'chef'], ['version_recipe', 'recipe1'], ['update_recipe', 'recipe1', 'Soup', ['water', 'salt'], ['boil', 'season']], ['edit_recipe', 'chef', 'recipe1', 'Tomato Soup', ['water', 'salt', 'tomato'], ['boil', 'blend']], ['version_recipe', 'recipe1'], ['rollback_recipe', 'recipe1', 1], ['get_recipe', 'recipe1'], ['version_recipe', 'recipe1']]
Expected Output: ['recipe1', True, [], True, True, ['1:Soup:water,salt:boil,season:system', '2:Tomato Soup:water,salt,tomato:boil,blend:chef'], True, ['Soup', 'water,salt', 'boil,season'], ['1:Soup:water,salt:boil,season:system', '2:Tomato Soup:water,salt,tomato:boil,blend:chef', '3:Soup:water,salt:boil,season:rollback']]
Explanation: A recipe has no history after add, gains versions after update/edit, and rollback appends a new version.
Input: [['version_recipe', 'recipe9'], ['rollback_recipe', 'recipe1', 1], ['add_recipe', 'A', [], []], ['version_recipe', 'recipe1'], ['rollback_recipe', 'recipe1', 1], ['add_user', 'u1'], ['edit_recipe', 'ghost', 'recipe1', 'B', [], []], ['update_recipe', 'recipe2', 'C', [], []]]
Expected Output: [[], False, 'recipe1', [], False, True, False, False]
Explanation: Edge cases: missing recipe, no history yet, missing user, and invalid update target.
Hints
- Store immutable snapshots for each recipe history; do not overwrite old versions.
- When rolling back, apply the historical state to the current recipe, then append a new history entry labeled `'rollback'`.