├── .fig └── autocomplete │ ├── .eslintrc.js │ ├── .gitignore │ ├── package-lock.json │ ├── package.json │ ├── src │ ├── README.md │ └── todo.ts │ └── tsconfig.json ├── .gitignore ├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── bin └── todo ├── lib ├── todo.rb └── todo │ ├── commands.rb │ ├── commands │ ├── add.rb │ ├── archive.rb │ ├── command.rb │ ├── delete.rb │ ├── list.rb │ ├── move.rb │ ├── open.rb │ ├── printable.rb │ ├── root.rb │ └── update.rb │ ├── file_repository.rb │ └── todo.rb ├── spec ├── spec_helper.rb └── todo │ ├── commands │ ├── add_spec.rb │ ├── archive_spec.rb │ ├── delete_spec.rb │ ├── list_spec.rb │ ├── move_spec.rb │ ├── open_spec.rb │ ├── printable_spec.rb │ ├── root_spec.rb │ └── update_spec.rb │ ├── file_repository_spec.rb │ └── todo_spec.rb ├── todo.gemspec └── usage.png /.fig/autocomplete/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["@fig/autocomplete"], 3 | }; 4 | -------------------------------------------------------------------------------- /.fig/autocomplete/.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | node_modules 3 | -------------------------------------------------------------------------------- /.fig/autocomplete/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "boilerplate-fig-autocomplete", 3 | "version": "1.0.0", 4 | "description": "Boilerplate Fig autocomplete spec package", 5 | "scripts": { 6 | "dev": "npx @withfig/autocomplete-tools dev", 7 | "create-spec": "npx @withfig/autocomplete-tools create-spec", 8 | "publish-spec": "npx @fig/publish-spec -i", 9 | "test": "tsc --noEmit && echo 'All specs passed validation.'", 10 | "build": "npx @withfig/autocomplete-tools compile", 11 | "lint": "eslint '**/*.ts' && npx prettier --check '**/*.ts'", 12 | "lint:fix": "eslint '**/*.ts' --fix && npx prettier --write '**/*.ts'" 13 | }, 14 | "engines": { 15 | "node": ">=16" 16 | }, 17 | "fig": { 18 | "dev": { 19 | "description": "Watching and compile .ts files in ./src", 20 | "icon": "fig://template?badge=🛠", 21 | "priority": 100 22 | }, 23 | "create-spec": { 24 | "description": "Create a new spec with the provided name in ./src" 25 | }, 26 | "publish-spec": { 27 | "description": "Publish a spec to Fig teams" 28 | }, 29 | "test": { 30 | "description": "Typecheck all .ts files in ./src" 31 | }, 32 | "build": { 33 | "description": "Compile all files in ./src" 34 | }, 35 | "lint": { 36 | "description": "Check for linting issues" 37 | }, 38 | "lint:fix": { 39 | "description": "Fix linting issues" 40 | } 41 | }, 42 | "prettier": { 43 | "trailingComma": "es5", 44 | "printWidth": 80 45 | }, 46 | "lint-staged": { 47 | "*.ts": [ 48 | "eslint --fix", 49 | "pretty-quick --staged" 50 | ] 51 | }, 52 | "author": "", 53 | "license": "MIT", 54 | "devDependencies": { 55 | "@fig/eslint-config-autocomplete": "latest", 56 | "@fig/publish-spec": "^1.2.0", 57 | "@types/node": "^16.11.33", 58 | "@withfig/autocomplete-tools": "^2.7.2", 59 | "@withfig/autocomplete-types": "latest", 60 | "eslint": "^8.15.0", 61 | "lint-staged": "^12.4.1", 62 | "prettier": "^2.6.2", 63 | "pretty-quick": "^3.1.3", 64 | "typescript": "^4.6.4" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /.fig/autocomplete/src/README.md: -------------------------------------------------------------------------------- 1 | This is the empty folder in which spec files are added. -------------------------------------------------------------------------------- /.fig/autocomplete/src/todo.ts: -------------------------------------------------------------------------------- 1 | const completionSpec: Fig.Spec = { 2 | name: "todo", 3 | description: "A TODO manager just for me", 4 | subcommands: [ 5 | { 6 | name: "list", 7 | description: "List TODOs", 8 | }, 9 | { 10 | name: "add", 11 | description: "Add a new TODO", 12 | args: [ 13 | { 14 | name: "title", 15 | description: "The title of a new TODO", 16 | }, 17 | { 18 | name: "position", 19 | description: "The position of a new TODO", 20 | isOptional: true, 21 | }, 22 | ], 23 | options: [ 24 | { 25 | name: ["--tag", "-t"], 26 | description: "The tag of a new TODO", 27 | isRepeatable: true, 28 | args: { 29 | name: "tag" 30 | } 31 | }, 32 | { 33 | name: ["--parent", "-p"], 34 | description: "The parent ID of a new TODO", 35 | args: { 36 | name: "parent id", 37 | }, 38 | }, 39 | { 40 | name: ["--open", "-o"], 41 | description: "Open a new TODO file", 42 | } 43 | ] 44 | }, 45 | { 46 | name: "open", 47 | description: "Open a TODO in editor", 48 | args: { 49 | name: "id", 50 | }, 51 | }, 52 | { 53 | name: "move", 54 | description: "Move a TODO", 55 | args: [ 56 | { 57 | name: "id", 58 | }, 59 | { 60 | name: "position", 61 | }, 62 | ], 63 | options: [ 64 | { 65 | name: ["--parent", "-p"], 66 | description: "The new parent ID of a moved TODO", 67 | args: { 68 | name: "id", 69 | }, 70 | }, 71 | ], 72 | }, 73 | { 74 | name: "delete", 75 | description: "Delete a TODO", 76 | args: { 77 | name: "id", 78 | isVariadic: true, 79 | }, 80 | }, 81 | { 82 | name: "done", 83 | description: "Mark a TODO as done", 84 | args: { 85 | name: "id", 86 | isVariadic: true, 87 | }, 88 | }, 89 | { 90 | name: "undone", 91 | description: "Mark a TODO as undone", 92 | args: { 93 | name: "id", 94 | isVariadic: true, 95 | }, 96 | }, 97 | { 98 | name: "wait", 99 | description: "Mark a TODO as waiting", 100 | args: { 101 | name: "id", 102 | isVariadic: true, 103 | }, 104 | }, 105 | { 106 | name: "archive", 107 | description: "Archive all done TODOs", 108 | }, 109 | ], 110 | options: [ 111 | { 112 | name: ["--help", "-h"], 113 | description: "Show help message", 114 | }, 115 | { 116 | name: ["--version", "-v"], 117 | description: "Show version", 118 | }, 119 | ], 120 | }; 121 | export default completionSpec; 122 | -------------------------------------------------------------------------------- /.fig/autocomplete/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "outDir": "./build", 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "lib": ["ESNext"], 8 | "noImplicitAny": false, 9 | "baseUrl": "./", 10 | "types": ["@withfig/autocomplete-types", "node"], 11 | }, 12 | "include": ["./src/**/*"], 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | spec/examples.txt 3 | *.gem 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## Unreleased 4 | 5 | ## 0.6.0 - 2022-08-21 6 | 7 | ### Added 8 | * `--tag`, `-t` option is added to `todo add` to create a new TODO file with tags. 9 | * `todo list` prints tags with color. 10 | 11 | ## 0.5.1 - 2022-08-14 12 | 13 | ### Added 14 | * Support auto completion for fig. 15 | 16 | ### Fixed 17 | * Double-quote titles with square brackets or colons to decode them correctly. 18 | * Fix default path for TODOs from current directory to $HOME/.todos. 19 | 20 | ## 0.5.0 - 2021-08-07 21 | 22 | ### Added 23 | * `--open`, `-o` flag is added to `todo add` to open a TODO file after creating it. 24 | * `` argument is added to `todo add` to create a TODO at given position. 25 | 26 | ### Changed 27 | * All codes are rewrited in Ruby. 28 | * `list` command shows TODOs in a new format. 29 | * `done`, `undone`, `wait` command updates the state of subtodos recursively. 30 | 31 | ## 0.4.2 - 2020-06-14 32 | 33 | ### Changed 34 | * `archive` command continues to archive all done TODOs even if some TODO files are not found. 35 | 36 | ## 0.4.1 37 | 38 | ### Added 39 | * `archive` command archives all done sub-TODOs of undone TODOs. 40 | 41 | ### Changed 42 | * Remove all IDs from index.json when there are not corresponding files under `$TODOS_PATH`. 43 | 44 | ## 0.4.0 45 | 46 | ### Changed 47 | * Rewrite all codes from scratch. 48 | 49 | ## 0.3.1 50 | 51 | ### Fixed 52 | * Fix `next` command to show a todo when the subtodos of the todo are all done. 53 | 54 | ## 0.3.0 55 | 56 | ### Added 57 | * `next` command to show a next undone todo. 58 | 59 | ### Changed 60 | * `done` command without any orders marks a next undone todo as done. 61 | 62 | ## 0.2.1 63 | 64 | ### Fixed 65 | * Fix a bug to delete multiple TODOs. 66 | 67 | ## 0.2.0 68 | 69 | ### Added 70 | * Support subtodos. 71 | 72 | ### Changed 73 | * Change the name of the file where todos saved from `.todo` to `.todo.json`. 74 | * Change the format of the file where todos saved from LTSV to JSON to support subtodos. 75 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | todo (0.6.0) 5 | 6 | GEM 7 | remote: https://rubygems.org/ 8 | specs: 9 | ast (2.4.2) 10 | diff-lcs (1.4.4) 11 | docile (1.4.0) 12 | parallel (1.20.1) 13 | parser (3.0.2.0) 14 | ast (~> 2.4.1) 15 | rainbow (3.0.0) 16 | regexp_parser (2.1.1) 17 | rexml (3.2.5) 18 | rspec (3.10.0) 19 | rspec-core (~> 3.10.0) 20 | rspec-expectations (~> 3.10.0) 21 | rspec-mocks (~> 3.10.0) 22 | rspec-core (3.10.1) 23 | rspec-support (~> 3.10.0) 24 | rspec-expectations (3.10.1) 25 | diff-lcs (>= 1.2.0, < 2.0) 26 | rspec-support (~> 3.10.0) 27 | rspec-mocks (3.10.2) 28 | diff-lcs (>= 1.2.0, < 2.0) 29 | rspec-support (~> 3.10.0) 30 | rspec-support (3.10.2) 31 | rubocop (1.18.3) 32 | parallel (~> 1.10) 33 | parser (>= 3.0.0.0) 34 | rainbow (>= 2.2.2, < 4.0) 35 | regexp_parser (>= 1.8, < 3.0) 36 | rexml 37 | rubocop-ast (>= 1.7.0, < 2.0) 38 | ruby-progressbar (~> 1.7) 39 | unicode-display_width (>= 1.4.0, < 3.0) 40 | rubocop-ast (1.8.0) 41 | parser (>= 3.0.1.1) 42 | rubocop-performance (1.11.4) 43 | rubocop (>= 1.7.0, < 2.0) 44 | rubocop-ast (>= 0.4.0) 45 | ruby-progressbar (1.11.0) 46 | simplecov (0.21.2) 47 | docile (~> 1.1) 48 | simplecov-html (~> 0.11) 49 | simplecov_json_formatter (~> 0.1) 50 | simplecov-html (0.12.3) 51 | simplecov_json_formatter (0.1.3) 52 | standard (1.1.5) 53 | rubocop (= 1.18.3) 54 | rubocop-performance (= 1.11.4) 55 | unicode-display_width (2.0.0) 56 | 57 | PLATFORMS 58 | x86_64-darwin-19 59 | x86_64-darwin-20 60 | x86_64-linux 61 | 62 | DEPENDENCIES 63 | rspec (~> 3.10) 64 | simplecov (~> 0.21) 65 | standard (~> 1.1) 66 | todo! 67 | 68 | BUNDLED WITH 69 | 2.2.22 70 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Naoto Kaneko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # todo 2 | 3 | ## Installation 4 | 5 | ### Homebrew 6 | 7 | ``` 8 | $ brew install naoty/misc/todo 9 | ``` 10 | 11 | ### Rubygems 12 | 13 | ``` 14 | $ gem install todo --version "" --source "https://rubygems.pkg.github.com/naoty" 15 | ``` 16 | 17 | ## Usage 18 | ![usage](./usage.png) 19 | 20 | ## Environment variables 21 | * `TODOS_PATH`: The root path of TODO files (default: `$HOME/.todos`) 22 | * `EDITOR`, `TODOS_EDITOR`: The editor to open TODO files 23 | 24 | ## Auto completion 25 | 26 | ### fig 27 | 28 | ``` 29 | npx @fig/publish-spec --spec-path .fig/autocomplete/src/todo.ts 30 | ``` 31 | 32 | ## Author 33 | 34 | [naoty](https://github.com/naoty) 35 | -------------------------------------------------------------------------------- /bin/todo: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | $LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) 4 | require 'todo' 5 | 6 | Todo::Commands::Root.new.run 7 | -------------------------------------------------------------------------------- /lib/todo.rb: -------------------------------------------------------------------------------- 1 | module Todo 2 | VERSION = "0.6.0" 3 | 4 | autoload :Commands, "todo/commands" 5 | autoload :FileRepository, "todo/file_repository" 6 | autoload :Todo, "todo/todo" 7 | end 8 | -------------------------------------------------------------------------------- /lib/todo/commands.rb: -------------------------------------------------------------------------------- 1 | module Todo::Commands 2 | autoload :Command, "todo/commands/command" 3 | 4 | autoload :Add, "todo/commands/add" 5 | autoload :Archive, "todo/commands/archive" 6 | autoload :Root, "todo/commands/root" 7 | autoload :Delete, "todo/commands/delete" 8 | autoload :List, "todo/commands/list" 9 | autoload :Move, "todo/commands/move" 10 | autoload :Open, "todo/commands/open" 11 | autoload :Printable, "todo/commands/printable" 12 | autoload :Update, "todo/commands/update" 13 | end 14 | -------------------------------------------------------------------------------- /lib/todo/commands/add.rb: -------------------------------------------------------------------------------- 1 | class Todo::Commands::Add < Todo::Commands::Command 2 | HELP_MESSAGE = <<~TEXT.freeze 3 | Usage: 4 | todo add (<position>) (-t | --tag <tag>)... (-p | --parent <id>) (-o | --open) 5 | todo add -h | --help 6 | 7 | Options: 8 | -h --help Show thid message 9 | -t --tag Add tag 10 | -p --parent Parent TODO ID 11 | -o --open Open TODO file after create 12 | TEXT 13 | 14 | def run(repository:) 15 | result = parse_arguments(arguments) 16 | if result.has_key?(:help) 17 | output.puts(HELP_MESSAGE) 18 | return 19 | elsif [:tags, :position, :parent_id, :title, :open].all? { |key| result.has_key?(key) } 20 | todo = repository.create( 21 | title: result[:title], 22 | tags: result[:tags], 23 | position: result[:position], 24 | parent_id: result[:parent_id]&.to_i 25 | ) 26 | repository.open(id: todo.id) if result[:open] 27 | else 28 | error_output.puts(HELP_MESSAGE) 29 | exit 1 30 | end 31 | 32 | todos = repository.list 33 | print_todos(todos, indent_width: 2) 34 | end 35 | 36 | private 37 | 38 | def parse_arguments(arguments) 39 | result = {tags: [], position: nil, parent_id: nil, open: false} 40 | arguments_copy = arguments.dup 41 | arguments_copy.each.with_index do |argument, index| 42 | case argument 43 | when "-h", "--help" 44 | result[:help] = true 45 | when "-t", "--tag" 46 | result[:tags] << arguments_copy.delete_at(index + 1) 47 | when "-p", "--parent" 48 | result[:parent_id] = arguments_copy.delete_at(index + 1) 49 | when "-o", "--open" 50 | result[:open] = true 51 | else 52 | if result[:title].nil? 53 | result[:title] = argument 54 | else 55 | result[:position] = argument.to_i 56 | end 57 | end 58 | end 59 | result 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/todo/commands/archive.rb: -------------------------------------------------------------------------------- 1 | class Todo::Commands::Archive < Todo::Commands::Command 2 | HELP_MESSAGE = <<~TEXT.freeze 3 | Usage: 4 | todo archive 5 | todo archive -h | --help 6 | 7 | Options: 8 | -h --help Show thid message 9 | TEXT 10 | 11 | def run(repository:) 12 | if arguments.first == "-h" || arguments.first == "--help" 13 | output.puts(HELP_MESSAGE) 14 | return 15 | end 16 | 17 | repository.archive 18 | 19 | todos = repository.list 20 | print_todos(todos, indent_width: 2) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/todo/commands/command.rb: -------------------------------------------------------------------------------- 1 | class Todo::Commands::Command 2 | include Todo::Commands::Printable 3 | 4 | attr_reader :arguments, :output, :error_output 5 | 6 | def initialize(arguments: ARGV, output: $stdout, error_output: $stderr) 7 | @arguments = arguments 8 | @output = output 9 | @error_output = error_output 10 | end 11 | 12 | def run 13 | raise NotImplementedError, "this method must be overwritten by subclass" 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/todo/commands/delete.rb: -------------------------------------------------------------------------------- 1 | class Todo::Commands::Delete < Todo::Commands::Command 2 | HELP_MESSAGE = <<~TEXT.freeze 3 | Usage: 4 | todo delete <id>... 5 | todo delete -h | --help 6 | 7 | Options: 8 | -h --help Show this message 9 | TEXT 10 | 11 | attr_reader :arguments, :output, :error_output 12 | 13 | def run(repository:) 14 | if arguments.empty? 15 | error_output.puts(HELP_MESSAGE) 16 | exit 1 17 | end 18 | 19 | if arguments.first == "-h" || arguments.first == "--help" 20 | output.puts(HELP_MESSAGE) 21 | return 22 | end 23 | 24 | repository.delete(ids: arguments.map(&:to_i)) 25 | 26 | todos = repository.list 27 | print_todos(todos, indent_width: 2) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/todo/commands/list.rb: -------------------------------------------------------------------------------- 1 | class Todo::Commands::List < Todo::Commands::Command 2 | HELP_MESSAGE = <<~TEXT.freeze 3 | Usage: 4 | todo list 5 | todo list -h | --help 6 | 7 | Options: 8 | -h --help Show this message 9 | TEXT 10 | 11 | def run(repository:) 12 | if arguments.first == "-h" || arguments.first == "--help" 13 | output.puts(HELP_MESSAGE) 14 | return 15 | end 16 | 17 | todos = repository.list 18 | print_todos(todos, indent_width: 2) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/todo/commands/move.rb: -------------------------------------------------------------------------------- 1 | class Todo::Commands::Move < Todo::Commands::Command 2 | HELP_MESSAGE = <<~TEXT.freeze 3 | Usage: 4 | todo move <id> <position> (-p | --parent <parent id>) 5 | todo move -h | --help 6 | 7 | Options: 8 | -h --help Show thid message 9 | TEXT 10 | 11 | def run(repository:) 12 | result = parse_arguments 13 | 14 | if result.has_key?(:help) 15 | output.puts(HELP_MESSAGE) 16 | return 17 | elsif result.has_key?(:invalid_id) 18 | error_output.puts("id is invalid: #{result[:invalid_id]}") 19 | exit 1 20 | elsif result.has_key?(:invalid_position) 21 | error_output.puts("position is invalid: #{result[:invalid_position]}") 22 | exit 1 23 | elsif result.has_key?(:empty_parent_id) 24 | error_output.puts("parent id is empty") 25 | exit 1 26 | elsif result.has_key?(:invalid_parent_id) 27 | error_output.puts("parent id is invalid: #{result[:invalid_parent_id]}") 28 | exit 1 29 | elsif [:id, :position, :parent_id].all? { |key| result.has_key?(key) } 30 | repository.move( 31 | id: result[:id].to_i, 32 | parent_id: result[:parent_id].to_i, 33 | position: result[:position].to_i 34 | ) 35 | elsif [:id, :position].all? { |key| result.has_key?(key) } 36 | repository.move( 37 | id: result[:id].to_i, 38 | position: result[:position].to_i 39 | ) 40 | else 41 | error_output.puts(HELP_MESSAGE) 42 | exit 1 43 | end 44 | 45 | todos = repository.list 46 | print_todos(todos, indent_width: 2) 47 | end 48 | 49 | private 50 | 51 | def parse_arguments 52 | result = {} 53 | 54 | arguments.each.with_index do |argument, index| 55 | case argument 56 | when "-h", "--help" 57 | result[:help] = true 58 | when "-p", "--parent" 59 | if arguments[index + 1].nil? 60 | result[:empty_parent_id] = true 61 | break 62 | end 63 | 64 | if /\d+/.match?(arguments[index + 1]) 65 | result[:parent_id] = arguments.delete_at(index + 1) 66 | next 67 | end 68 | 69 | result[:invalid_parent_id] = arguments.delete_at(index + 1) 70 | else 71 | if result[:id].nil? 72 | if /\d+/.match?(argument) 73 | result[:id] = argument 74 | next 75 | end 76 | 77 | result[:invalid_id] = argument 78 | break 79 | end 80 | 81 | if /\d+/.match?(argument) 82 | result[:position] = argument 83 | next 84 | end 85 | 86 | result[:invalid_position] = argument 87 | end 88 | end 89 | 90 | result 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/todo/commands/open.rb: -------------------------------------------------------------------------------- 1 | class Todo::Commands::Open < Todo::Commands::Command 2 | HELP_MESSAGE = <<~TEXT.freeze 3 | Usage: 4 | todo open <id> 5 | todo open -h | --help 6 | 7 | Options: 8 | -h --help Show this message 9 | TEXT 10 | 11 | def run(repository:) 12 | if arguments.empty? 13 | error_output.puts(HELP_MESSAGE) 14 | exit 1 15 | end 16 | 17 | if arguments.first == "-h" || arguments.first == "--help" 18 | output.puts(HELP_MESSAGE) 19 | return 20 | end 21 | 22 | repository.open(id: arguments.first.to_i) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/todo/commands/printable.rb: -------------------------------------------------------------------------------- 1 | module Todo::Commands::Printable 2 | def output 3 | raise NotImplementedError 4 | end 5 | 6 | def print_todos(todos, indent_width: 0) 7 | indent = " " * indent_width 8 | id_width = todos.map { |todo| todo.id.digits.length }.max 9 | 10 | todos.each do |todo| 11 | output.puts(format_todo(todo, indent: indent, id_width: id_width)) 12 | print_todos(todo.subtodos, indent_width: indent_width + id_width + 3) # " | " is 3 chars 13 | end 14 | end 15 | 16 | private 17 | 18 | def format_todo(todo, indent:, id_width:) 19 | right_aligned_id = todo.id.to_s.rjust(id_width, " ") 20 | 21 | decorated_title = 22 | case todo.state 23 | when :undone then todo.title 24 | when :waiting then "\e[2m#{todo.title}" # dim 25 | when :done then "\e[2;9m#{todo.title}" # dim + strikethrough 26 | end 27 | 28 | decorated_tags = todo.tags 29 | .map { |tag| "\e[36m##{tag}" } 30 | .join(" ") 31 | 32 | result = "#{indent}#{right_aligned_id} | #{decorated_title}" 33 | result += " #{decorated_tags}" unless todo.tags.empty? 34 | result += "\e[0m" if [:waiting, :done].include?(todo.state) || !todo.tags.empty? 35 | result 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/todo/commands/root.rb: -------------------------------------------------------------------------------- 1 | require "pathname" 2 | 3 | class Todo::Commands::Root < Todo::Commands::Command 4 | HELP_MESSAGE = <<~TEXT.freeze 5 | Usage: 6 | todo list 7 | todo add <title> (<position>) (-t | --tag <tag>)... (-p | --parent <id>) (-o | --open) 8 | todo open <id> 9 | todo move <id> <position> (-p | --parent <id>) 10 | todo delete <id>... 11 | todo done <id>... 12 | todo undone <id>... 13 | todo wait <id>... 14 | todo archive 15 | todo -h | --help 16 | todo -v | --version 17 | 18 | Options: 19 | -h --help Show this message 20 | -v --version Show version 21 | TEXT 22 | 23 | def run 24 | if arguments.empty? 25 | error_output.puts(HELP_MESSAGE) 26 | exit 1 27 | end 28 | 29 | if arguments.first == "-h" || arguments.first == "--help" 30 | output.puts(HELP_MESSAGE) 31 | return 32 | end 33 | 34 | if arguments.first == "-v" || arguments.first == "--version" 35 | output.puts(Todo::VERSION) 36 | return 37 | end 38 | 39 | if !ENV.has_key?("TODOS_EDITOR") && !ENV.has_key?("EDITOR") 40 | error_output.puts('EDITOR or TODOS_EDITOR is required') 41 | return 42 | end 43 | 44 | command = build_command(name: arguments.first, arguments: arguments[1..]) 45 | repository = Todo::FileRepository.new( 46 | root_path: ENV["TODOS_PATH"] || default_root_path, 47 | opener: ENV["TODOS_EDITOR"] || ENV["EDITOR"], 48 | error_output: error_output 49 | ) 50 | command.run(repository: repository) 51 | rescue CommandNotFound => exception 52 | error_output.puts(exception.message) 53 | exit 1 54 | end 55 | 56 | private 57 | 58 | class CommandNotFound < StandardError 59 | attr_reader :unknown_name 60 | 61 | def initialize(unknown_name:) 62 | super 63 | @unknown_name = unknown_name 64 | end 65 | 66 | def message 67 | "command not found: #{unknown_name}" 68 | end 69 | end 70 | 71 | def build_command(name:, arguments:) 72 | case name 73 | when "add" 74 | Todo::Commands::Add.new(arguments: arguments, output: output, error_output: error_output) 75 | when "list" 76 | Todo::Commands::List.new(arguments: arguments, output: output, error_output: error_output) 77 | when "open" 78 | Todo::Commands::Open.new(arguments: arguments, output: output, error_output: error_output) 79 | when "move" 80 | Todo::Commands::Move.new(arguments: arguments, output: output, error_output: error_output) 81 | when "delete" 82 | Todo::Commands::Delete.new(arguments: arguments, output: output, error_output: error_output) 83 | when "done" 84 | Todo::Commands::Update.new(arguments: arguments, state: :done, output: output, error_output: error_output) 85 | when "undone" 86 | Todo::Commands::Update.new(arguments: arguments, state: :undone, output: output, error_output: error_output) 87 | when "wait" 88 | Todo::Commands::Update.new(arguments: arguments, state: :waiting, name: "wait", output: output, error_output: error_output) 89 | when "archive" 90 | Todo::Commands::Archive.new(arguments: arguments, output: output, error_output: error_output) 91 | else 92 | raise CommandNotFound.new(unknown_name: name) 93 | end 94 | end 95 | 96 | def default_root_path 97 | Pathname.new(ENV.fetch("HOME")).join(".todos").to_s 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/todo/commands/update.rb: -------------------------------------------------------------------------------- 1 | class Todo::Commands::Update < Todo::Commands::Command 2 | attr_reader :state, :name 3 | 4 | def initialize(arguments:, state:, name: nil, output: $stdout, error_output: $stderr) 5 | super(arguments: arguments, output: output, error_output: error_output) 6 | @state = state 7 | @name = name || state 8 | end 9 | 10 | def run(repository:) 11 | if arguments.empty? 12 | error_output.puts(help_message) 13 | exit 1 14 | end 15 | 16 | if arguments.first == "-h" || arguments.first == "--help" 17 | output.puts(help_message) 18 | return 19 | end 20 | 21 | repository.update(ids: arguments.map(&:to_i), state: state) 22 | 23 | todos = repository.list 24 | print_todos(todos, indent_width: 2) 25 | end 26 | 27 | def help_message 28 | <<~TEXT 29 | Usage: 30 | todo #{name} <id>... 31 | todo #{name} -h | --help 32 | 33 | Options: 34 | -h --help Show this message 35 | TEXT 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/todo/file_repository.rb: -------------------------------------------------------------------------------- 1 | require "fileutils" 2 | require "json" 3 | require "pathname" 4 | require "yaml" 5 | 6 | class Todo::FileRepository 7 | attr_reader :root_path, :opener, :error_output 8 | 9 | def initialize(root_path:, opener:, error_output:) 10 | @root_path = Pathname.new(root_path) 11 | @opener = opener 12 | @error_output = error_output 13 | 14 | setup 15 | end 16 | 17 | def list(id: nil) 18 | index = load_index 19 | todos = (index[:todos][id.to_s.to_sym] || []).map do |id| 20 | todo_path = root_path.join("#{id}.md") 21 | 22 | unless todo_path.exist? 23 | error_output.puts("todo file is not found: #{todo_path}") 24 | next nil 25 | end 26 | 27 | todo = decode(id: id, text: todo_path.read) 28 | 29 | if todo.nil? 30 | error_output.puts("todo file is broken: #{todo_path}") 31 | next nil 32 | end 33 | 34 | list(id: todo.id).each { |subtodo| todo.append_subtodo(subtodo) } 35 | todo 36 | end 37 | todos.compact 38 | end 39 | 40 | def create(title:, tags: [], position: nil, parent_id: nil) 41 | next_id = load_next_id 42 | 43 | todo = Todo::Todo.new(id: next_id, title: title, state: :undone, tags: tags, body: "") 44 | todo_path = root_path.join("#{todo.id}.md") 45 | encoded_todo = encode(todo) 46 | todo_path.open("wb") { |file| file.puts(encoded_todo) } 47 | 48 | if position.nil? 49 | insert_index = -1 50 | elsif position <= 0 51 | insert_index = position 52 | else 53 | insert_index = position - 1 54 | end 55 | 56 | index = load_index 57 | index[:todos][parent_id.to_s.to_sym] ||= [] 58 | index[:todos][parent_id.to_s.to_sym].insert(insert_index, todo.id).compact! 59 | save_index(index) 60 | 61 | todo 62 | end 63 | 64 | def delete(ids:) 65 | index = load_index 66 | 67 | ids_to_be_deleted = ids.dup 68 | ids.each do |id| 69 | subtodo_ids = index[:todos][id.to_s.to_sym] 70 | next if subtodo_ids.nil? 71 | 72 | ids_to_be_deleted -= subtodo_ids 73 | end 74 | 75 | ids_to_be_deleted.each do |id| 76 | todo_path = root_path.join("#{id}.md") 77 | 78 | unless todo_path.exist? 79 | error_output.puts("todo file is not found: #{todo_path}") 80 | next 81 | end 82 | 83 | todo_path.delete 84 | 85 | index[:todos].each do |parent_id, subtodo_ids| 86 | if parent_id.to_s.to_i == id 87 | delete(ids: subtodo_ids) 88 | index[:todos].delete(id.to_s.to_sym) 89 | end 90 | 91 | if subtodo_ids.include?(id) 92 | index[:todos][parent_id].delete(id) 93 | index[:metadata][:missingIds] << id 94 | end 95 | end 96 | end 97 | 98 | save_index(index) 99 | end 100 | 101 | def update(ids:, state:) 102 | index = load_index 103 | 104 | ids_to_be_updated = ids.dup 105 | ids.each do |id| 106 | subtodo_ids = index[:todos][id.to_s.to_sym] 107 | next if subtodo_ids.nil? 108 | 109 | ids_to_be_updated -= subtodo_ids 110 | end 111 | 112 | ids_to_be_updated.each do |id| 113 | todo_path = root_path.join("#{id}.md") 114 | 115 | unless todo_path.exist? 116 | error_output.puts("todo file is not found: #{todo_path}") 117 | next 118 | end 119 | 120 | todo = decode(id: id, text: todo_path.read) 121 | todo.state = state 122 | encoded_todo = encode(todo) 123 | todo_path.open("wb") { |file| file.puts(encoded_todo) } 124 | 125 | subtodo_ids = index[:todos][id.to_s.to_sym] 126 | next if subtodo_ids.nil? 127 | 128 | update(ids: subtodo_ids, state: state) 129 | end 130 | end 131 | 132 | def archive(todos: list) 133 | index = load_index 134 | 135 | todos.each do |todo| 136 | if todo.should_be_archived? 137 | todo_path = root_path.join("#{todo.id}.md") 138 | archived_todo_path = root_path.join("archived", "#{todo.id}.md") 139 | FileUtils.mv(todo_path, archived_todo_path) 140 | 141 | index[:todos][todo.parent&.id.to_s.to_sym].delete(todo.id) 142 | index[:todos].delete(todo.parent&.id.to_s.to_sym) if index[:todos][todo.parent&.id.to_s.to_sym].empty? 143 | index[:archived][todo.parent&.id.to_s.to_sym] ||= [] 144 | index[:archived][todo.parent&.id.to_s.to_sym] << todo.id 145 | end 146 | 147 | archive(todos: todo.subtodos) 148 | end 149 | 150 | save_index(index) 151 | end 152 | 153 | def move(id:, position:, parent_id: nil) 154 | if id == parent_id 155 | error_output.puts("moving a todo under itself is forbidden") 156 | return 157 | end 158 | 159 | index = load_index 160 | 161 | index[:archived].each do |_, subtodos| 162 | if subtodos.include?(id) 163 | error_output.puts("moving an archived todo is forbidden") 164 | return nil 165 | end 166 | 167 | if subtodos.include?(parent_id) 168 | error_output.puts("moving a todo under an archived todo is forbidden") 169 | return nil 170 | end 171 | end 172 | 173 | todo_found = false 174 | parent_found = false 175 | index[:todos].each do |parent_key, subtodos| 176 | if subtodos.include?(id) 177 | todo_found = true 178 | subtodos.delete(id) 179 | index[:todos].delete(parent_key) if subtodos.empty? 180 | end 181 | 182 | if subtodos.include?(parent_id) 183 | parent_found = true 184 | end 185 | end 186 | 187 | if !todo_found 188 | error_output.puts("todo is not found: #{id}") 189 | return 190 | end 191 | 192 | if !parent_id.nil? && !parent_found 193 | error_output.puts("parent is not found: #{parent_id}") 194 | return 195 | end 196 | 197 | insert_index = position > 0 ? position - 1 : position 198 | index[:todos][parent_id.to_s.to_sym] ||= [] 199 | index[:todos][parent_id.to_s.to_sym].insert(insert_index, id).compact! 200 | 201 | save_index(index) 202 | end 203 | 204 | def open(id:) 205 | todo_path = root_path.join("#{id}.md") 206 | todo_path = root_path.join("archived", "#{id}.md") unless todo_path.exist? 207 | 208 | unless todo_path.exist? 209 | error_output.puts("todo is not found: #{id}") 210 | return 211 | end 212 | 213 | system("#{opener} #{todo_path}") 214 | end 215 | 216 | private 217 | 218 | def setup 219 | create_index_if_not_exist 220 | create_archived_directory_if_not_exist 221 | end 222 | 223 | def create_index_if_not_exist 224 | index_path = root_path.join("index.json") 225 | return if index_path.exist? 226 | 227 | save_index(default_index) 228 | end 229 | 230 | def create_archived_directory_if_not_exist 231 | archived_path = root_path.join("archived") 232 | return if archived_path.exist? 233 | 234 | archived_path.mkdir 235 | end 236 | 237 | def load_next_id 238 | index = load_index 239 | missing_ids = index.dig(:metadata, :missingIds) 240 | 241 | if missing_ids.empty? 242 | last_id = index.dig(:metadata, :lastId) || 0 243 | next_id = last_id + 1 244 | index[:metadata][:lastId] = next_id 245 | else 246 | next_id = index[:metadata][:missingIds].shift 247 | end 248 | 249 | save_index(index) 250 | next_id 251 | end 252 | 253 | def default_index 254 | { 255 | todos: {}, 256 | archived: {}, 257 | metadata: { 258 | lastId: 0, 259 | missingIds: [] 260 | } 261 | } 262 | end 263 | 264 | def load_index 265 | @loaded_index ||= begin 266 | index_path = root_path.join("index.json") 267 | JSON.parse(index_path.read, symbolize_names: true) 268 | rescue JSON::ParserError 269 | error_output.puts("index file is broken: #{index_path}") 270 | default_index 271 | end 272 | end 273 | 274 | def save_index(index) 275 | index_json = JSON.pretty_generate(index) 276 | root_path.join("index.json").open("wb") { |file| file.puts(index_json) } 277 | end 278 | 279 | def encode(todo) 280 | metadata = { 281 | title: todo.title.match?(/[\[\]:]/) ? %("#{todo.title}") : todo.title, 282 | state: todo.state 283 | } 284 | 285 | unless todo.tags.empty? 286 | tags_string = todo.tags.map { |tag| %("#{tag}") }.join(", ") 287 | metadata[:tags] = %([#{tags_string}]) 288 | end 289 | 290 | front_matter = metadata.map { |key, value| "#{key}: #{value}" }.join("\n") 291 | 292 | <<~TEXT 293 | --- 294 | #{front_matter} 295 | --- 296 | 297 | #{todo.body} 298 | TEXT 299 | end 300 | 301 | def decode(id:, text:) 302 | parts = text.split("---", 3).map(&:strip) 303 | 304 | return nil if parts.length < 3 305 | 306 | front_matter = YAML.safe_load(parts[1], symbolize_names: true) 307 | Todo::Todo.new( 308 | id: id, 309 | title: front_matter[:title], 310 | state: front_matter[:state]&.to_sym || :undone, # TODO: handle unknown status 311 | tags: front_matter[:tags] || [], 312 | body: parts[2] 313 | ) 314 | rescue Psych::SyntaxError 315 | nil 316 | end 317 | end 318 | -------------------------------------------------------------------------------- /lib/todo/todo.rb: -------------------------------------------------------------------------------- 1 | class Todo::Todo 2 | attr_accessor :state, :subtodos, :parent 3 | attr_reader :id, :title, :tags, :body 4 | 5 | def initialize(id:, title:, state: :undone, tags: [], body: "", subtodos: []) 6 | @id = id 7 | @title = title 8 | @state = state 9 | @tags = tags 10 | @body = body 11 | @subtodos = subtodos 12 | end 13 | 14 | def append_subtodo(subtodo) 15 | subtodos << subtodo 16 | subtodo.parent = self 17 | end 18 | 19 | def done? 20 | state == :done 21 | end 22 | 23 | def should_be_archived? 24 | (!parent.nil? && parent.should_be_archived?) || done? 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "simplecov" 3 | 4 | SimpleCov.start do 5 | enable_coverage :branch 6 | primary_coverage :branch 7 | 8 | add_filter "/spec/" 9 | end 10 | 11 | require "todo" 12 | 13 | RSpec.configure do |config| 14 | config.example_status_persistence_file_path = "./spec/examples.txt" 15 | end 16 | 17 | RSpec::Matchers.define_negated_matcher :not_change, :change 18 | -------------------------------------------------------------------------------- /spec/todo/commands/add_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "stringio" 3 | 4 | RSpec.describe Todo::Commands::Add do 5 | describe "#run" do 6 | let(:output) { StringIO.new } 7 | let(:error_output) { StringIO.new } 8 | let(:repository) { instance_double(Todo::FileRepository) } 9 | 10 | before do 11 | allow(repository).to receive(:list).and_return([]) 12 | end 13 | 14 | context "when arguments are empty" do 15 | it "puts help message to error output" do 16 | add = described_class.new(arguments: [], output: output, error_output: error_output) 17 | add.run(repository: repository) 18 | rescue SystemExit 19 | # ignore exit 20 | ensure 21 | expect(error_output.string).to eq(described_class::HELP_MESSAGE) 22 | end 23 | 24 | it "exits with status code 1" do 25 | add = described_class.new(arguments: [], output: output, error_output: error_output) 26 | expect { 27 | add.run(repository: repository) 28 | }.to raise_error(an_instance_of(SystemExit).and(having_attributes({status: 1}))) 29 | end 30 | end 31 | 32 | ["-h", "--help"].each do |flag| 33 | context "when arguments include '#{flag}' flag" do 34 | it "puts help message to output" do 35 | add = described_class.new(arguments: [flag], output: output, error_output: error_output) 36 | add.run(repository: repository) 37 | expect(output.string).to eq(described_class::HELP_MESSAGE) 38 | end 39 | end 40 | end 41 | 42 | context "when arguments include title" do 43 | it "calls FileRepository#create with title" do 44 | expect(repository).to receive(:create).with({title: "dummy", tags: [], position: nil, parent_id: nil}) 45 | add = described_class.new(arguments: ["dummy"], output: output, error_output: error_output) 46 | add.run(repository: repository) 47 | end 48 | end 49 | 50 | context "when arguments include title and position" do 51 | it "calls FileRepository#create with title and position" do 52 | expect(repository).to receive(:create).with({title: "dummy", tags: [], position: 0, parent_id: nil}) 53 | add = described_class.new(arguments: ["dummy", "0"], output: output, error_output: error_output) 54 | add.run(repository: repository) 55 | end 56 | end 57 | 58 | ["-t", "--tag"].each do |option| 59 | context "when arguments include '#{option}' option" do 60 | it "calls FileRepository#create with title and tag" do 61 | expect(repository).to receive(:create).with({title: "dummy", tags: ["dummy"], position: nil, parent_id: nil}) 62 | add = described_class.new(arguments: [option, "dummy", "dummy"]) 63 | add.run(repository: repository) 64 | end 65 | end 66 | 67 | context "when arguments include multiple '#{option}' options" do 68 | it "calls FileRepository#create with title and multiple tags" do 69 | expect(repository).to receive(:create).with({title: "dummy", tags: ["dummy1", "dummy2"], position: nil, parent_id: nil}) 70 | add = described_class.new(arguments: [option, "dummy1", option, "dummy2", "dummy"]) 71 | add.run(repository: repository) 72 | end 73 | end 74 | end 75 | 76 | ["-p", "--parent"].each do |option| 77 | context "when arguments include '#{option}' option" do 78 | it "calls FileRepository#create with title and parent_id" do 79 | expect(repository).to receive(:create).with({title: "dummy", tags: [], position: nil, parent_id: 1}) 80 | add = described_class.new(arguments: [option, "1", "dummy"], output: output, error_output: error_output) 81 | add.run(repository: repository) 82 | end 83 | end 84 | 85 | context "when arguments include '#{option}' option with invalid value" do 86 | it "puts help message to error output" do 87 | add = described_class.new(arguments: [option, "dummy"], output: output, error_output: error_output) 88 | add.run(repository: repository) 89 | rescue SystemExit 90 | # ignore exit 91 | ensure 92 | expect(error_output.string).to eq(described_class::HELP_MESSAGE) 93 | end 94 | 95 | it "exits with status code 1" do 96 | add = described_class.new(arguments: [option, "dummy"], output: output, error_output: error_output) 97 | expect { 98 | add.run(repository: repository) 99 | }.to raise_error(an_instance_of(SystemExit).and(having_attributes({status: 1}))) 100 | end 101 | end 102 | end 103 | 104 | ["-o", "--open"].each do |flag| 105 | context "when arguments include '#{flag}' flag" do 106 | let(:arguments) { ["dummy", flag] } 107 | let(:todo) { Todo::Todo.new(id: 1, title: "dummy") } 108 | 109 | it "calls FileRepository#open" do 110 | allow(repository).to receive(:create).and_return(todo) 111 | expect(repository).to receive(:open).with(id: 1) 112 | add = described_class.new(arguments: arguments, output: output, error_output: error_output) 113 | add.run(repository: repository) 114 | end 115 | end 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /spec/todo/commands/archive_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe Todo::Commands::Archive do 4 | describe "#run" do 5 | let(:output) { StringIO.new } 6 | let(:error_output) { StringIO.new } 7 | let(:repository) { instance_double(Todo::FileRepository) } 8 | 9 | before do 10 | allow(repository).to receive(:list).and_return([]) 11 | end 12 | 13 | context "when arguments are empty" do 14 | it "calls FileRepository#archive" do 15 | expect(repository).to receive(:archive) 16 | archive = described_class.new(arguments: [], output: output, error_output: error_output) 17 | archive.run(repository: repository) 18 | end 19 | end 20 | 21 | ["-h", "--help"].each do |flag| 22 | context "when arguments include '#{flag}' flag" do 23 | it "puts help message to output" do 24 | archive = described_class.new(arguments: [flag], output: output, error_output: error_output) 25 | archive.run(repository: repository) 26 | expect(output.string).to eq(described_class::HELP_MESSAGE) 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/todo/commands/delete_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "stringio" 3 | 4 | RSpec.describe Todo::Commands::Delete do 5 | describe "#run" do 6 | let(:output) { StringIO.new } 7 | let(:error_output) { StringIO.new } 8 | let(:repository) { instance_double(Todo::FileRepository) } 9 | 10 | before do 11 | allow(repository).to receive(:list).and_return([]) 12 | end 13 | 14 | context "when arguments are empty" do 15 | it "puts help message to error output" do 16 | delete = described_class.new(arguments: [], output: output, error_output: error_output) 17 | delete.run(repository: repository) 18 | rescue SystemExit 19 | # ignore exit 20 | ensure 21 | expect(error_output.string).to eq(described_class::HELP_MESSAGE) 22 | end 23 | 24 | it "exits with status code 1" do 25 | delete = described_class.new(arguments: [], output: output, error_output: error_output) 26 | expect { 27 | delete.run(repository: repository) 28 | }.to raise_error(an_instance_of(SystemExit).and(having_attributes({status: 1}))) 29 | end 30 | end 31 | 32 | ["-h", "--help"].each do |flag| 33 | context "when arguments include '#{flag}' flag" do 34 | it "puts help message to output" do 35 | delete = described_class.new(arguments: [flag], output: output, error_output: error_output) 36 | delete.run(repository: repository) 37 | expect(output.string).to eq(described_class::HELP_MESSAGE) 38 | end 39 | end 40 | end 41 | 42 | context "when arguments include IDs" do 43 | it "calls Todo::FileRepository#delete" do 44 | delete = described_class.new(arguments: ["1"], output: output, error_output: error_output) 45 | expect(repository).to receive(:delete).with(ids: [1]) 46 | delete.run(repository: repository) 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/todo/commands/list_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "stringio" 3 | 4 | RSpec.describe Todo::Commands::List do 5 | let(:output) { StringIO.new } 6 | let(:error_output) { StringIO.new } 7 | let(:repository) { instance_double(Todo::FileRepository) } 8 | 9 | let(:todos) do 10 | [ 11 | Todo::Todo.new(id: 1, title: "dummy", state: :undone) 12 | ] 13 | end 14 | 15 | describe "#run" do 16 | context "when arguments are empty" do 17 | let(:arguments) { [] } 18 | 19 | it "calls FileRepository#list" do 20 | expect(repository).to receive(:list).and_return(todos) 21 | list = described_class.new(arguments: arguments, output: output, error_output: error_output) 22 | list.run(repository: repository) 23 | end 24 | 25 | it "calls Printable#print_todos" do 26 | allow(repository).to receive(:list).and_return(todos) 27 | list = described_class.new(arguments: arguments, output: output, error_output: error_output) 28 | expect(list).to receive(:print_todos) 29 | list.run(repository: repository) 30 | end 31 | end 32 | 33 | ["-h", "--help"].each do |flag| 34 | context "when arguments include #{flag} flag" do 35 | let(:arguments) { [flag] } 36 | 37 | it "puts help message" do 38 | allow(repository).to receive(:list).and_return(todos) 39 | list = described_class.new(arguments: arguments, output: output, error_output: error_output) 40 | list.run(repository: repository) 41 | expect(output.string).to eq(described_class::HELP_MESSAGE) 42 | end 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/todo/commands/move_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "stringio" 3 | 4 | RSpec.describe Todo::Commands::Move do 5 | describe "#run" do 6 | let(:output) { StringIO.new } 7 | let(:error_output) { StringIO.new } 8 | let(:repository) { instance_double(Todo::FileRepository) } 9 | 10 | before do 11 | allow(repository).to receive(:list).and_return([]) 12 | end 13 | 14 | context "when arguments are empty" do 15 | it "puts help message to error output" do 16 | move = described_class.new(arguments: [], output: output, error_output: error_output) 17 | move.run(repository: repository) 18 | rescue SystemExit 19 | # ignore exit 20 | ensure 21 | expect(error_output.string).to eq(described_class::HELP_MESSAGE) 22 | end 23 | 24 | it "exits with status code 1" do 25 | move = described_class.new(arguments: [], output: output, error_output: error_output) 26 | expect { 27 | move.run(repository: repository) 28 | }.to raise_error(an_instance_of(SystemExit).and(having_attributes({status: 1}))) 29 | end 30 | end 31 | 32 | ["-h", "--help"].each do |flag| 33 | context "when arguments include '#{flag}' flag" do 34 | it "puts help message to output" do 35 | move = described_class.new(arguments: [flag], output: output, error_output: error_output) 36 | move.run(repository: repository) 37 | expect(output.string).to eq(described_class::HELP_MESSAGE) 38 | end 39 | end 40 | end 41 | 42 | context "when arguments include only id" do 43 | it "puts help message to error output" do 44 | move = described_class.new(arguments: ["1"], output: output, error_output: error_output) 45 | move.run(repository: repository) 46 | rescue SystemExit 47 | # ignore exit 48 | ensure 49 | expect(error_output.string).to eq(described_class::HELP_MESSAGE) 50 | end 51 | 52 | it "exits with status code 1" do 53 | move = described_class.new(arguments: ["1"], output: output, error_output: error_output) 54 | expect { 55 | move.run(repository: repository) 56 | }.to raise_error(an_instance_of(SystemExit).and(having_attributes({status: 1}))) 57 | end 58 | end 59 | 60 | context "when arguments include both id and position" do 61 | context "when id is invalid" do 62 | it "puts message to error output" do 63 | move = described_class.new(arguments: ["dummy", "2"], output: output, error_output: error_output) 64 | move.run(repository: repository) 65 | rescue SystemExit 66 | # ignore exit 67 | ensure 68 | expect(error_output.string).to eq("id is invalid: dummy\n") 69 | end 70 | 71 | it "exits with status code 1" do 72 | move = described_class.new(arguments: ["dummy", "2"], output: output, error_output: error_output) 73 | expect { 74 | move.run(repository: repository) 75 | }.to raise_error(an_instance_of(SystemExit).and(having_attributes({status: 1}))) 76 | end 77 | end 78 | 79 | context "when position is invalid" do 80 | it "puts message to error output" do 81 | move = described_class.new(arguments: ["1", "dummy"], output: output, error_output: error_output) 82 | move.run(repository: repository) 83 | rescue SystemExit 84 | # ignore exit 85 | ensure 86 | expect(error_output.string).to eq("position is invalid: dummy\n") 87 | end 88 | 89 | it "exits with status code 1" do 90 | move = described_class.new(arguments: ["1", "dummy"], output: output, error_output: error_output) 91 | expect { 92 | move.run(repository: repository) 93 | }.to raise_error(an_instance_of(SystemExit).and(having_attributes({status: 1}))) 94 | end 95 | end 96 | 97 | context "when both id and position is valid" do 98 | it "calls FileRepository#move with id and position" do 99 | expect(repository).to receive(:move).with(id: 1, position: 2) 100 | move = described_class.new(arguments: ["1", "2"], output: output, error_output: error_output) 101 | move.run(repository: repository) 102 | end 103 | end 104 | end 105 | 106 | ["-p", "--parent"].each do |option| 107 | context "when arguments include '#{option} option'" do 108 | it "calls# FileRepository#move with id and position and parent_id" do 109 | expect(repository).to receive(:move).with(id: 1, position: 2, parent_id: 3) 110 | move = described_class.new(arguments: ["1", "2", option, "3"], output: output, error_output: error_output) 111 | move.run(repository: repository) 112 | end 113 | end 114 | 115 | context "when arguments include '#{option}' option without value" do 116 | it "puts message to error output" do 117 | move = described_class.new(arguments: ["1", "2", option], output: output, error_output: error_output) 118 | move.run(repository: repository) 119 | rescue SystemExit 120 | # ignore exit 121 | ensure 122 | expect(error_output.string).to eq("parent id is empty\n") 123 | end 124 | 125 | it "exits with status code 1" do 126 | move = described_class.new(arguments: ["1", "2", option], output: output, error_output: error_output) 127 | expect { 128 | move.run(repository: repository) 129 | }.to raise_error(an_instance_of(SystemExit).and(having_attributes({status: 1}))) 130 | end 131 | end 132 | 133 | context "when arguments include '#{option}' option with invalid value" do 134 | it "puts help message to error output" do 135 | move = described_class.new(arguments: ["1", "2", option, "dummy"], output: output, error_output: error_output) 136 | move.run(repository: repository) 137 | rescue SystemExit 138 | # ignore exit 139 | ensure 140 | expect(error_output.string).to eq("parent id is invalid: dummy\n") 141 | end 142 | 143 | it "exits with status code 1" do 144 | move = described_class.new(arguments: ["1", "2", option, "dummy"], output: output, error_output: error_output) 145 | expect { 146 | move.run(repository: repository) 147 | }.to raise_error(an_instance_of(SystemExit).and(having_attributes({status: 1}))) 148 | end 149 | end 150 | end 151 | end 152 | end 153 | -------------------------------------------------------------------------------- /spec/todo/commands/open_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe Todo::Commands::Open do 4 | describe "#run" do 5 | let(:output) { StringIO.new } 6 | let(:error_output) { StringIO.new } 7 | let(:repository) { instance_double(Todo::FileRepository) } 8 | 9 | context "when arguments are empty" do 10 | it "puts help message to error output" do 11 | command = described_class.new(arguments: [], output: output, error_output: error_output) 12 | command.run(repository: repository) 13 | rescue SystemExit 14 | # ignore exit 15 | ensure 16 | expect(error_output.string).to eq(described_class::HELP_MESSAGE) 17 | end 18 | 19 | it "exits with status code 1" do 20 | command = described_class.new(arguments: [], output: output, error_output: error_output) 21 | expect { 22 | command.run(repository: repository) 23 | }.to raise_error(an_instance_of(SystemExit).and(having_attributes({status: 1}))) 24 | end 25 | end 26 | 27 | ["-h", "--help"].each do |flag| 28 | context "when arguments include '#{flag}' flag" do 29 | it "puts help message to output" do 30 | command = described_class.new(arguments: [flag], output: output, error_output: error_output) 31 | command.run(repository: repository) 32 | expect(output.string).to eq(described_class::HELP_MESSAGE) 33 | end 34 | end 35 | end 36 | 37 | context "when arguments include an ID" do 38 | it "calls FileRepository#open with the ID" do 39 | expect(repository).to receive(:open).with(id: 1) 40 | command = described_class.new(arguments: ["1"], output: output, error_output: error_output) 41 | command.run(repository: repository) 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/todo/commands/printable_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "stringio" 3 | 4 | RSpec.describe Todo::Commands::Printable do 5 | let(:printer_klass) do 6 | Class.new do 7 | include Todo::Commands::Printable 8 | 9 | def output 10 | @output ||= StringIO.new 11 | end 12 | end 13 | end 14 | 15 | let(:printer) { printer_klass.new } 16 | 17 | describe "#print_todos" do 18 | context "when todos have no subtodos" do 19 | let(:todos) do 20 | [ 21 | Todo::Todo.new(id: 2, title: "dummy", state: :waiting), 22 | Todo::Todo.new(id: 1, title: "dummy", state: :undone), 23 | Todo::Todo.new(id: 10, title: "dummy", state: :done) 24 | ] 25 | end 26 | 27 | it "prints todos to output at the same level" do 28 | printer.print_todos(todos) 29 | expect(printer.output.string).to eq(<<~TEXT) 30 | 2 | \e[2mdummy\e[0m 31 | 1 | dummy 32 | 10 | \e[2;9mdummy\e[0m 33 | TEXT 34 | end 35 | end 36 | 37 | context "when todos have subtodos" do 38 | let(:todos) do 39 | [ 40 | Todo::Todo.new(id: 1, title: "dummy", subtodos: [ 41 | Todo::Todo.new(id: 2, title: "dummy", subtodos: [ 42 | Todo::Todo.new(id: 3, title: "dummy") 43 | ]) 44 | ]) 45 | ] 46 | end 47 | 48 | it "prints todos in nested form" do 49 | printer.print_todos(todos) 50 | expect(printer.output.string).to eq(<<~TEXT) 51 | 1 | dummy 52 | 2 | dummy 53 | 3 | dummy 54 | TEXT 55 | end 56 | end 57 | 58 | context "when todos have tags" do 59 | let(:todos) do 60 | [ 61 | Todo::Todo.new(id: 1, title: "dummy", state: :undone, tags: ["dummy1"]), 62 | Todo::Todo.new(id: 2, title: "dummy", state: :done, tags: ["dummy1", "dummy2"]), 63 | Todo::Todo.new(id: 3, title: "dummy", state: :waiting, tags: ["dummy1", "dummy2"]) 64 | ] 65 | end 66 | 67 | it "prints todos with tags" do 68 | printer.print_todos(todos) 69 | expect(printer.output.string).to eq(<<~TEXT) 70 | 1 | dummy \e[36m#dummy1\e[0m 71 | 2 | \e[2;9mdummy \e[36m#dummy1 \e[36m#dummy2\e[0m 72 | 3 | \e[2mdummy \e[36m#dummy1 \e[36m#dummy2\e[0m 73 | TEXT 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /spec/todo/commands/root_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "stringio" 3 | 4 | RSpec.describe Todo::Commands::Root do 5 | describe "#run" do 6 | let(:output) { StringIO.new } 7 | let(:error_output) { StringIO.new } 8 | 9 | shared_examples "exits with status code 1" do |arguments:| 10 | it "exits with status code 1" do 11 | cli = described_class.new(arguments: arguments, output: output, error_output: error_output) 12 | expect { cli.run }.to raise_error(an_instance_of(SystemExit).and(having_attributes(status: 1))) 13 | end 14 | end 15 | 16 | context "when arguments are empty" do 17 | it "puts help message to error output" do 18 | cli = described_class.new(arguments: [], output: output, error_output: error_output) 19 | cli.run 20 | rescue SystemExit 21 | # ignore exit 22 | ensure 23 | expect(error_output.string).to eq(described_class::HELP_MESSAGE) 24 | end 25 | 26 | include_examples "exits with status code 1", arguments: [] 27 | end 28 | 29 | ["-h", "--help"].each do |flag| 30 | context "when arguments include '#{flag}' flag" do 31 | it "puts help message to output" do 32 | cli = described_class.new(arguments: [flag], output: output, error_output: error_output) 33 | cli.run 34 | expect(output.string).to eq(described_class::HELP_MESSAGE) 35 | end 36 | end 37 | end 38 | 39 | ["-v", "--version"].each do |flag| 40 | context "when arguments include '#{flag}' flag" do 41 | it "puts version to output" do 42 | cli = described_class.new(arguments: [flag], output: output, error_output: error_output) 43 | cli.run 44 | expect(output.string).to eq("#{Todo::VERSION}\n") 45 | end 46 | end 47 | end 48 | 49 | context "when arguments include unknown command" do 50 | it "puts error message to error output" do 51 | cli = described_class.new(arguments: ["unknown"], output: output, error_output: error_output) 52 | cli.run 53 | rescue SystemExit 54 | # ignore exit 55 | ensure 56 | expect(error_output.string).to eq("command not found: unknown\n") 57 | end 58 | 59 | include_examples "exits with status code 1", arguments: ["unknown"] 60 | end 61 | 62 | { 63 | "add" => Todo::Commands::Add, 64 | "list" => Todo::Commands::List, 65 | "open" => Todo::Commands::Open, 66 | "move" => Todo::Commands::Move, 67 | "delete" => Todo::Commands::Delete, 68 | "done" => Todo::Commands::Update, 69 | "undone" => Todo::Commands::Update, 70 | "wait" => Todo::Commands::Update, 71 | "archive" => Todo::Commands::Archive 72 | }.each do |command, klass| 73 | context "when arguments include '#{command}' command" do 74 | it "calls #{klass}#run" do 75 | instance = instance_double(klass) 76 | allow(klass).to receive(:new).and_return(instance) 77 | expect(instance).to receive(:run) 78 | 79 | cli = described_class.new(arguments: [command], output: output, error_output: error_output) 80 | cli.run 81 | end 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /spec/todo/commands/update_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "stringio" 3 | 4 | RSpec.describe Todo::Commands::Update do 5 | describe "#run" do 6 | let(:output) { StringIO.new } 7 | let(:error_output) { StringIO.new } 8 | let(:repository) { instance_double(Todo::FileRepository) } 9 | 10 | before do 11 | allow(repository).to receive(:list).and_return([]) 12 | end 13 | 14 | context "when arguments are empty" do 15 | it "puts help message to error output" do 16 | update = described_class.new(arguments: [], state: :done, output: output, error_output: error_output) 17 | update.run(repository: repository) 18 | rescue SystemExit 19 | # ignore exit 20 | ensure 21 | expect(error_output.string).to eq(update.help_message) 22 | end 23 | 24 | it "exits with status code 1" do 25 | update = described_class.new(arguments: [], state: :done, output: output, error_output: error_output) 26 | expect { 27 | update.run(repository: repository) 28 | }.to raise_error(an_instance_of(SystemExit).and(having_attributes({status: 1}))) 29 | end 30 | end 31 | 32 | ["-h", "--help"].each do |flag| 33 | context "when arguments include '#{flag}' flag" do 34 | it "puts help message to output" do 35 | update = described_class.new(arguments: [flag], state: :done, output: output, error_output: error_output) 36 | update.run(repository: repository) 37 | expect(output.string).to eq(update.help_message) 38 | end 39 | end 40 | end 41 | 42 | context "when arguments include IDs" do 43 | it "calls Todo::FileRepository#update" do 44 | expect(repository).to receive(:update).with(ids: [1], state: :done) 45 | update = described_class.new(arguments: ["1"], state: :done, output: output, error_output: error_output) 46 | update.run(repository: repository) 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/todo/file_repository_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | require "fileutils" 4 | require "json" 5 | require "pathname" 6 | require "stringio" 7 | require "tmpdir" 8 | 9 | RSpec.describe Todo::FileRepository do 10 | let(:output) { StringIO.new } 11 | let(:error_output) { StringIO.new } 12 | let(:archived_path) { Pathname.pwd.join("archived") } 13 | let(:index_path) { Pathname.pwd.join("index.json") } 14 | 15 | around do |example| 16 | Dir.mktmpdir do |dir| 17 | Dir.chdir(dir) do 18 | example.run 19 | end 20 | end 21 | end 22 | 23 | describe "#initialize" do 24 | context "when index file doesn't exist" do 25 | it "creates index file" do 26 | expect { 27 | Todo::FileRepository.new( 28 | root_path: Pathname.pwd, 29 | opener: "open", 30 | error_output: error_output, 31 | ) 32 | }.to change { index_path.exist? }.from(false).to(true) 33 | 34 | index = JSON.parse(index_path.read, symbolize_names: true) 35 | expect(index).to include({ 36 | todos: {}, 37 | archived: {}, 38 | metadata: { 39 | lastId: 0, 40 | missingIds: [] 41 | } 42 | }) 43 | end 44 | end 45 | 46 | context "when index file exists" do 47 | let(:index_json) do 48 | JSON.pretty_generate({ 49 | todos: { 50 | "": [1] 51 | }, 52 | archived: {}, 53 | metadata: { 54 | lastId: 1, 55 | missingIds: [] 56 | } 57 | }) 58 | end 59 | 60 | before do 61 | index_path.open("wb") { |file| file.puts(index_json) } 62 | end 63 | 64 | it "doesn't overwrite" do 65 | Todo::FileRepository.new( 66 | root_path: Pathname.pwd, 67 | opener: "open", 68 | error_output: error_output, 69 | ) 70 | expect(index_path.read.chomp).to eq(index_json) 71 | end 72 | end 73 | 74 | context "when archived directory doesn't exist" do 75 | it "creates archived directory" do 76 | expect { 77 | Todo::FileRepository.new( 78 | root_path: Pathname.pwd, 79 | opener: "open", 80 | error_output: error_output, 81 | ) 82 | }.to change { archived_path.exist? }.from(false).to(true) 83 | expect(archived_path).to be_directory 84 | end 85 | end 86 | 87 | context "when archived directory exists" do 88 | before do 89 | archived_path.mkdir 90 | end 91 | 92 | it "doesn't raise any errors" do 93 | expect { 94 | Todo::FileRepository.new( 95 | root_path: Pathname.pwd, 96 | opener: "open", 97 | error_output: error_output, 98 | ) 99 | }.not_to raise_error 100 | end 101 | end 102 | end 103 | 104 | describe "#list" do 105 | let!(:repository) do 106 | Todo::FileRepository.new( 107 | root_path: Pathname.pwd, 108 | opener: "open", 109 | error_output: error_output, 110 | ) 111 | end 112 | 113 | let(:todo_id) { 1 } 114 | let(:todo_path) { Pathname.pwd.join("#{todo_id}.md") } 115 | let(:subtodo_id) { 2 } 116 | let(:subtodo_path) { Pathname.pwd.join("#{subtodo_id}.md") } 117 | let(:not_found_todo_id) { 100 } 118 | 119 | shared_context "when a todo file exists" do 120 | before do 121 | FileUtils.touch(todo_path) 122 | end 123 | end 124 | 125 | shared_context "when a todo file is normal" do 126 | before do 127 | todo_path.open("wb+") do |file| 128 | file.puts(<<~TEXT) 129 | --- 130 | title: dummy 131 | state: undone 132 | --- 133 | 134 | body 135 | TEXT 136 | end 137 | end 138 | end 139 | 140 | shared_context "when a todo file is empty" do 141 | before do 142 | todo_path.truncate(0) 143 | end 144 | end 145 | 146 | shared_context "when a todo file doesn't include front matter" do 147 | before do 148 | todo_path.open("wb+") { |file| file.puts("body") } 149 | end 150 | end 151 | 152 | shared_context "when a todo file includes broken front matter" do 153 | before do 154 | todo_path.open("wb+") do |file| 155 | file.puts(<<~TEXT) 156 | --- 157 | title: % 158 | state: undone 159 | --- 160 | 161 | body 162 | TEXT 163 | end 164 | end 165 | end 166 | 167 | shared_context "when an index file is normal" do 168 | before do 169 | index_path.open("wb+") do |file| 170 | file.puts(JSON.pretty_generate({ 171 | todos: { 172 | "": [todo_id] 173 | }, 174 | archived: {}, 175 | metadata: { 176 | lastId: todo_id, 177 | missingIds: [] 178 | } 179 | })) 180 | end 181 | end 182 | end 183 | 184 | shared_context "when an index file is empty" do 185 | before do 186 | index_path.truncate(0) 187 | end 188 | end 189 | 190 | shared_context "when an index file includes ID which todo file doesn't exist" do 191 | before do 192 | index_path.open("wb+") do |file| 193 | file.puts(JSON.pretty_generate({ 194 | todos: { 195 | "": [not_found_todo_id] 196 | }, 197 | archived: {}, 198 | metadata: { 199 | lastId: not_found_todo_id, 200 | missingIds: [] 201 | } 202 | })) 203 | end 204 | end 205 | end 206 | 207 | shared_context "when a todo has a subtodo" do 208 | before do 209 | subtodo_path.open("wb+") do |file| 210 | file.puts(<<~TEXT) 211 | --- 212 | title: dummy 213 | state: undone 214 | --- 215 | 216 | body 217 | TEXT 218 | end 219 | 220 | index_path.open("wb+") do |file| 221 | file.puts(JSON.pretty_generate({ 222 | todos: { 223 | "": [todo_id], 224 | "#{todo_id}": [subtodo_id] 225 | }, 226 | archived: {}, 227 | metadata: { 228 | lastId: subtodo_id, 229 | missingIds: [] 230 | } 231 | })) 232 | end 233 | end 234 | end 235 | 236 | context "when todo doesn't exist" do 237 | it "returns empty array" do 238 | expect(repository.list).to be_empty 239 | end 240 | end 241 | 242 | context "when a todo exists but the todo file is empty" do 243 | include_context "when a todo file exists" 244 | include_context "when a todo file is empty" 245 | include_context "when an index file is normal" 246 | 247 | it "returns empty array" do 248 | expect(repository.list).to be_empty 249 | end 250 | 251 | it "puts message to error output" do 252 | repository.list 253 | expect(error_output.string).to eq("todo file is broken: #{todo_path}\n") 254 | end 255 | end 256 | 257 | context "when a todo exists but the todo file doesn't include front matter" do 258 | include_context "when a todo file exists" 259 | include_context "when a todo file doesn't include front matter" 260 | include_context "when an index file is normal" 261 | 262 | it "returns empty array" do 263 | expect(repository.list).to be_empty 264 | end 265 | 266 | it "puts message to error output" do 267 | repository.list 268 | expect(error_output.string).to eq("todo file is broken: #{todo_path}\n") 269 | end 270 | end 271 | 272 | context "when a todo exists but the todo file includes broken front matter" do 273 | include_context "when a todo file exists" 274 | include_context "when a todo file includes broken front matter" 275 | include_context "when an index file is normal" 276 | 277 | it "returns empty array" do 278 | expect(repository.list).to be_empty 279 | end 280 | 281 | it "puts message to error output" do 282 | repository.list 283 | expect(error_output.string).to eq("todo file is broken: #{todo_path}\n") 284 | end 285 | end 286 | 287 | context "when a todo exists but an index file is empty" do 288 | include_context "when a todo file exists" 289 | include_context "when a todo file is normal" 290 | include_context "when an index file is empty" 291 | 292 | it "returns empty array" do 293 | expect(repository.list).to be_empty 294 | end 295 | 296 | it "puts message to error output" do 297 | repository.list 298 | expect(error_output.string).to eq("index file is broken: #{index_path}\n") 299 | end 300 | end 301 | 302 | context "when a todo exists but an index file includes ID which todo file doesn't exist" do 303 | include_context "when a todo file exists" 304 | include_context "when a todo file is normal" 305 | include_context "when an index file includes ID which todo file doesn't exist" 306 | 307 | it "returns empty array" do 308 | expect(repository.list).to be_empty 309 | end 310 | 311 | it "puts message to error output" do 312 | repository.list 313 | not_found_todo_path = Pathname.pwd.join("#{not_found_todo_id}.md") 314 | expect(error_output.string).to eq("todo file is not found: #{not_found_todo_path}\n") 315 | end 316 | end 317 | 318 | context "when a todo file and an index file is normal" do 319 | include_context "when a todo file exists" 320 | include_context "when a todo file is normal" 321 | include_context "when an index file is normal" 322 | 323 | it "returns array containing a todo" do 324 | expect(repository.list).to contain_exactly( 325 | an_instance_of(Todo::Todo).and(having_attributes( 326 | id: todo_id 327 | )) 328 | ) 329 | end 330 | end 331 | 332 | context "when a todo has a subtodo" do 333 | include_context "when a todo file exists" 334 | include_context "when a todo file is normal" 335 | include_context "when an index file is normal" 336 | include_context "when a todo has a subtodo" 337 | 338 | it "returns array containing a todo with a subtodo" do 339 | expect(repository.list).to contain_exactly( 340 | an_instance_of(Todo::Todo).and(having_attributes( 341 | id: todo_id, 342 | subtodos: a_collection_containing_exactly( 343 | an_instance_of(Todo::Todo).and(having_attributes( 344 | id: subtodo_id 345 | )) 346 | ) 347 | )) 348 | ) 349 | end 350 | end 351 | end 352 | 353 | describe "#create" do 354 | let!(:repository) do 355 | Todo::FileRepository.new( 356 | root_path: Pathname.pwd, 357 | opener: "open", 358 | error_output: error_output, 359 | ) 360 | end 361 | 362 | shared_context "when title includes square brackets" do 363 | let(:title) { "[dummy]" } 364 | end 365 | 366 | shared_context "when title includes colons" do 367 | let(:title) { ":dummy" } 368 | end 369 | 370 | shared_context "when title doesn't any special characters" do 371 | let(:title) { "dummy" } 372 | end 373 | 374 | shared_context "when tags are empty" do 375 | let(:tags) { [] } 376 | end 377 | 378 | shared_context "when tags are present" do 379 | let(:tags) { ["dummy"] } 380 | end 381 | 382 | shared_context "when missing IDs are empty" do 383 | before do 384 | index_json = JSON.pretty_generate({ 385 | todos: {}, 386 | archived: {}, 387 | metadata: { 388 | lastId: 0, 389 | missingIds: [] 390 | } 391 | }) 392 | index_path.open("wb") { |file| file.puts(index_json) } 393 | end 394 | end 395 | 396 | shared_context "when missing IDs are present" do 397 | let(:missing_id) { 1 } 398 | 399 | before do 400 | index_json = JSON.pretty_generate({ 401 | todos: {}, 402 | archived: {}, 403 | metadata: { 404 | lastId: 2, 405 | missingIds: [missing_id] 406 | } 407 | }) 408 | index_path.open("wb") { |file| file.puts(index_json) } 409 | end 410 | end 411 | 412 | shared_context "when position is nil" do 413 | let(:position) { nil } 414 | end 415 | 416 | shared_context "when position is -1" do 417 | let(:position) { -1 } 418 | 419 | before do 420 | index_json = JSON.pretty_generate({ 421 | todos: {"": [1]}, 422 | archived: {}, 423 | metadata: { 424 | lastId: 1, 425 | missingIds: [] 426 | } 427 | }) 428 | index_path.open("wb") { |file| file.puts(index_json) } 429 | end 430 | end 431 | 432 | shared_context "when position is 0" do 433 | let(:position) { 0 } 434 | 435 | before do 436 | index_json = JSON.pretty_generate({ 437 | todos: {"": [1]}, 438 | archived: {}, 439 | metadata: { 440 | lastId: 1, 441 | missingIds: [] 442 | } 443 | }) 444 | index_path.open("wb") { |file| file.puts(index_json) } 445 | end 446 | end 447 | 448 | shared_context "when position is larger than or equal to the length of todos" do 449 | let(:position) { 10 } 450 | 451 | before do 452 | index_json = JSON.pretty_generate({ 453 | todos: {"": [1]}, 454 | archived: {}, 455 | metadata: { 456 | lastId: 1, 457 | missingIds: [] 458 | } 459 | }) 460 | index_path.open("wb") { |file| file.puts(index_json) } 461 | end 462 | end 463 | 464 | shared_context "when parent_id is given" do 465 | let!(:parent_id) do 466 | parent = repository.create(title: "dummy") 467 | parent.id 468 | end 469 | end 470 | 471 | shared_context "when parent_id isn't given" do 472 | let(:parent_id) { nil } 473 | end 474 | 475 | context "when missing IDs are empty and position is 0" do 476 | include_context "when title doesn't any special characters" 477 | include_context "when tags are empty" 478 | include_context "when missing IDs are empty" 479 | include_context "when position is 0" 480 | include_context "when parent_id isn't given" 481 | 482 | it "updates an index file" do 483 | expect { 484 | repository.create(title: title, tags: tags, position: position, parent_id: parent_id) 485 | }.to change { 486 | JSON.parse(index_path.read, symbolize_names: true) 487 | }.to({ 488 | todos: { 489 | "": [2, 1] 490 | }, 491 | archived: {}, 492 | metadata: { 493 | lastId: 2, 494 | missingIds: [] 495 | } 496 | }) 497 | end 498 | end 499 | 500 | context "when missing IDs are empty and position is -1" do 501 | include_context "when title doesn't any special characters" 502 | include_context "when tags are empty" 503 | include_context "when missing IDs are empty" 504 | include_context "when position is -1" 505 | include_context "when parent_id isn't given" 506 | 507 | it "updates an index file" do 508 | expect { 509 | repository.create(title: title, tags: tags, position: position, parent_id: parent_id) 510 | }.to change { 511 | JSON.parse(index_path.read, symbolize_names: true) 512 | }.to({ 513 | todos: { 514 | "": [1, 2] 515 | }, 516 | archived: {}, 517 | metadata: { 518 | lastId: 2, 519 | missingIds: [] 520 | } 521 | }) 522 | end 523 | end 524 | 525 | context "when missing IDs are empty and position is larger than or equal to the length of todos" do 526 | include_context "when title doesn't any special characters" 527 | include_context "when tags are empty" 528 | include_context "when missing IDs are empty" 529 | include_context "when position is larger than or equal to the length of todos" 530 | include_context "when parent_id isn't given" 531 | 532 | it "updates an index file" do 533 | expect { 534 | repository.create(title: title, tags: tags, position: position, parent_id: parent_id) 535 | }.to change { 536 | JSON.parse(index_path.read, symbolize_names: true) 537 | }.to({ 538 | todos: { 539 | "": [1, 2] 540 | }, 541 | archived: {}, 542 | metadata: { 543 | lastId: 2, 544 | missingIds: [] 545 | } 546 | }) 547 | end 548 | end 549 | 550 | context "when missing IDs are empty and parent_id isn't given" do 551 | include_context "when title doesn't any special characters" 552 | include_context "when tags are empty" 553 | include_context "when missing IDs are empty" 554 | include_context "when position is nil" 555 | include_context "when parent_id isn't given" 556 | 557 | it "creates a todo file" do 558 | todo_path = Pathname.pwd.join("1.md") 559 | expect { 560 | repository.create(title: title, tags: tags, position: position, parent_id: parent_id) 561 | }.to change { todo_path.exist? }.from(false).to(true) 562 | 563 | expect(todo_path.read).to eq(<<~TEXT) 564 | --- 565 | title: dummy 566 | state: undone 567 | --- 568 | 569 | 570 | TEXT 571 | end 572 | 573 | it "updates an index file" do 574 | expect { 575 | repository.create(title: title, position: position, parent_id: parent_id) 576 | }.to change { 577 | JSON.parse(index_path.read, symbolize_names: true) 578 | }.to({ 579 | todos: { 580 | "": [1] 581 | }, 582 | archived: {}, 583 | metadata: { 584 | lastId: 1, 585 | missingIds: [] 586 | } 587 | }) 588 | end 589 | end 590 | 591 | context "when title includes square brackets, missing IDs are empty and parent_id isn't given" do 592 | include_context "when title includes square brackets" 593 | include_context "when tags are empty" 594 | include_context "when missing IDs are empty" 595 | include_context "when position is nil" 596 | include_context "when parent_id isn't given" 597 | 598 | it "creates a todo file with the title double-quoted" do 599 | todo_path = Pathname.pwd.join("1.md") 600 | expect { 601 | repository.create(title: title, tags: tags, position: position, parent_id: parent_id) 602 | }.to change { todo_path.exist? }.from(false).to(true) 603 | 604 | expect(todo_path.read).to eq(<<~TEXT) 605 | --- 606 | title: "[dummy]" 607 | state: undone 608 | --- 609 | 610 | 611 | TEXT 612 | end 613 | end 614 | 615 | context "when title includes square brackets, missing IDs are empty and parent_id isn't given" do 616 | include_context "when title includes colons" 617 | include_context "when tags are empty" 618 | include_context "when missing IDs are empty" 619 | include_context "when position is nil" 620 | include_context "when parent_id isn't given" 621 | 622 | it "creates a todo file with the title double-quoted" do 623 | todo_path = Pathname.pwd.join("1.md") 624 | expect { 625 | repository.create(title: title, tags: tags, position: position, parent_id: parent_id) 626 | }.to change { todo_path.exist? }.from(false).to(true) 627 | 628 | expect(todo_path.read).to eq(<<~TEXT) 629 | --- 630 | title: ":dummy" 631 | state: undone 632 | --- 633 | 634 | 635 | TEXT 636 | end 637 | end 638 | 639 | context "when tags are present, missing IDs are empty and parent_id isn't given" do 640 | include_context "when title doesn't any special characters" 641 | include_context "when tags are present" 642 | include_context "when missing IDs are empty" 643 | include_context "when position is nil" 644 | include_context "when parent_id isn't given" 645 | 646 | it "creates a todo file with tags" do 647 | todo_path = Pathname.pwd.join("1.md") 648 | expect { 649 | repository.create(title: title, tags: tags, position: position, parent_id: parent_id) 650 | }.to change { todo_path.exist? }.from(false).to(true) 651 | 652 | expect(todo_path.read).to eq(<<~TEXT) 653 | --- 654 | title: dummy 655 | state: undone 656 | tags: ["dummy"] 657 | --- 658 | 659 | 660 | TEXT 661 | end 662 | end 663 | 664 | context "when missing IDs are empty and parent_id is given" do 665 | include_context "when title doesn't any special characters" 666 | include_context "when tags are empty" 667 | include_context "when missing IDs are empty" 668 | include_context "when position is nil" 669 | include_context "when parent_id is given" 670 | 671 | it "creates a todo file" do 672 | todo_path = Pathname.pwd.join("2.md") 673 | expect { 674 | repository.create(title: title, tags: tags, position: position, parent_id: parent_id) 675 | }.to change { todo_path.exist? }.from(false).to(true) 676 | 677 | expect(todo_path.read).to eq(<<~TEXT) 678 | --- 679 | title: dummy 680 | state: undone 681 | --- 682 | 683 | 684 | TEXT 685 | end 686 | 687 | it "updates an index file" do 688 | expect { 689 | repository.create(title: title, position: position, parent_id: parent_id) 690 | }.to change { 691 | JSON.parse(index_path.read, symbolize_names: true) 692 | }.to({ 693 | todos: { 694 | "": [1], 695 | "1": [2] 696 | }, 697 | archived: {}, 698 | metadata: { 699 | lastId: 2, 700 | missingIds: [] 701 | } 702 | }) 703 | end 704 | end 705 | 706 | context "when missing IDs are present and parent_id isn't given" do 707 | include_context "when title doesn't any special characters" 708 | include_context "when tags are empty" 709 | include_context "when missing IDs are present" 710 | include_context "when position is nil" 711 | include_context "when parent_id isn't given" 712 | 713 | it "creates a todo file with a missing ID" do 714 | todo_path = Pathname.pwd.join("#{missing_id}.md") 715 | expect { 716 | repository.create(title: title, tags: tags, position: position, parent_id: parent_id) 717 | }.to change { todo_path.exist? }.from(false).to(true) 718 | end 719 | 720 | it "removes a missing ID" do 721 | expect { 722 | repository.create(title: title, parent_id: parent_id) 723 | }.to change { 724 | JSON.parse(index_path.read, symbolize_names: true) 725 | }.to({ 726 | todos: { 727 | "": [missing_id] 728 | }, 729 | archived: {}, 730 | metadata: { 731 | lastId: 2, 732 | missingIds: [] 733 | } 734 | }) 735 | end 736 | end 737 | end 738 | 739 | describe "#delete" do 740 | let!(:repository) do 741 | Todo::FileRepository.new( 742 | root_path: Pathname.pwd, 743 | opener: "open", 744 | error_output: error_output, 745 | ) 746 | end 747 | 748 | let!(:todo) { repository.create(title: "dummy") } 749 | let!(:subtodo) { repository.create(title: "dummy", parent_id: todo.id) } 750 | let!(:subsubtodo) { repository.create(title: "dummy", parent_id: subtodo.id) } 751 | 752 | context "when todo file with given ID doesn't exist" do 753 | let(:ids) { [100] } 754 | 755 | it "outputs message to error output" do 756 | repository.delete(ids: ids) 757 | 758 | todo_path = Pathname.pwd.join("#{ids.first}.md") 759 | expect(error_output.string).to eq("todo file is not found: #{todo_path}\n") 760 | end 761 | end 762 | 763 | context "when given ID is a parent todo's ID" do 764 | let(:ids) { [todo.id] } 765 | 766 | it "deletes all descendant todo files" do 767 | todo_path = Pathname.pwd.join("#{todo.id}.md") 768 | subtodo_path = Pathname.pwd.join("#{subtodo.id}.md") 769 | subsubtodo_path = Pathname.pwd.join("#{subsubtodo.id}.md") 770 | 771 | expect { repository.delete(ids: ids) }.to change { todo_path.exist? }.from(true).to(false) 772 | .and change { subtodo_path.exist? }.from(true).to(false) 773 | .and change { subsubtodo_path.exist? }.from(true).to(false) 774 | end 775 | 776 | it "updates index file" do 777 | before_index = {todos: {"": [todo.id], "#{todo.id}": [subtodo.id], "#{subtodo.id}": [subsubtodo.id]}, archived: {}, metadata: {lastId: subsubtodo.id, missingIds: []}} 778 | after_index = {todos: {"": []}, archived: {}, metadata: {lastId: subsubtodo.id, missingIds: [todo.id, subtodo.id, subsubtodo.id]}} 779 | 780 | expect { 781 | repository.delete(ids: ids) 782 | }.to change { 783 | JSON.parse(index_path.read, symbolize_names: true) 784 | }.from(before_index).to(after_index) 785 | end 786 | end 787 | 788 | context "when given IDs include a parent todo's ID and subtodo's ID" do 789 | let(:ids) { [todo.id, subtodo.id] } 790 | 791 | it "doesn't put any messages to error output" do 792 | repository.delete(ids: ids) 793 | expect(error_output.string).to be_empty 794 | end 795 | end 796 | end 797 | 798 | describe "#update" do 799 | let!(:repository) do 800 | Todo::FileRepository.new( 801 | root_path: Pathname.pwd, 802 | opener: "open", 803 | error_output: error_output, 804 | ) 805 | end 806 | 807 | let!(:todo) { repository.create(title: "dummy") } 808 | let!(:subtodo) { repository.create(title: "dummy", parent_id: todo.id) } 809 | let!(:subsubtodo) { repository.create(title: "dummy", parent_id: subtodo.id) } 810 | 811 | context "when todo file with given ID doesn't exist" do 812 | let(:ids) { [100] } 813 | 814 | it "puts error message to error output" do 815 | repository.update(ids: ids, state: :undone) 816 | 817 | todo_path = Pathname.pwd.join("#{ids.first}.md") 818 | expect(error_output.string).to eq("todo file is not found: #{todo_path}\n") 819 | end 820 | end 821 | 822 | context "when given ID is a parent todo's ID" do 823 | let(:ids) { [todo.id] } 824 | 825 | it "updates all descendant todos" do 826 | todo_path = Pathname.pwd.join("#{todo.id}.md") 827 | subtodo_path = Pathname.pwd.join("#{subtodo.id}.md") 828 | subsubtodo_path = Pathname.pwd.join("#{subsubtodo.id}.md") 829 | 830 | expect { repository.update(ids: ids, state: :done) }.to change { todo_path.read.include?("state: done") }.to(true) 831 | .and change { subtodo_path.read.include?("state: done") }.to(true) 832 | .and change { subsubtodo_path.read.include?("state: done") }.to(true) 833 | end 834 | end 835 | 836 | context "when given IDs include a parent todo's ID and subtodo's ID" do 837 | let(:ids) { [todo.id, subtodo.id] } 838 | 839 | it "doesn't put any messages to error output" do 840 | repository.update(ids: ids, state: :done) 841 | expect(error_output.string).to be_empty 842 | end 843 | end 844 | end 845 | 846 | describe "#archive" do 847 | let!(:repository) do 848 | Todo::FileRepository.new( 849 | root_path: Pathname.pwd, 850 | opener: "open", 851 | error_output: error_output, 852 | ) 853 | end 854 | 855 | let(:todo_id) { 1 } 856 | let(:todo_path) { Pathname.pwd.join("#{todo_id}.md") } 857 | let(:archived_todo_path) { archived_path.join("#{todo_id}.md") } 858 | let(:subtodo_id) { 2 } 859 | let(:subtodo_path) { Pathname.pwd.join("#{subtodo_id}.md") } 860 | let(:archived_subtodo_path) { archived_path.join("#{subtodo_id}.md") } 861 | let(:unknown_todo_id) { 100 } 862 | 863 | shared_context "when parent todo is undone" do 864 | before do 865 | todo_path.open("wb") do |file| 866 | file.puts(<<~TEXT) 867 | --- 868 | title: dummy 869 | state: undone 870 | --- 871 | 872 | body 873 | TEXT 874 | end 875 | end 876 | end 877 | 878 | shared_context "when parent todo is done" do 879 | before do 880 | todo_path.open("wb") do |file| 881 | file.puts(<<~TEXT) 882 | --- 883 | title: dummy 884 | state: done 885 | --- 886 | 887 | body 888 | TEXT 889 | end 890 | end 891 | end 892 | 893 | shared_context "when subtodo is undone" do 894 | before do 895 | subtodo_path.open("wb") do |file| 896 | file.puts(<<~TEXT) 897 | --- 898 | title: dummy 899 | state: undone 900 | --- 901 | 902 | body 903 | TEXT 904 | end 905 | end 906 | end 907 | 908 | shared_context "when subtodo is done" do 909 | before do 910 | subtodo_path.open("wb") do |file| 911 | file.puts(<<~TEXT) 912 | --- 913 | title: dummy 914 | state: done 915 | --- 916 | 917 | body 918 | TEXT 919 | end 920 | end 921 | end 922 | 923 | shared_context "when index file is normal" do 924 | before do 925 | index_json = JSON.pretty_generate({ 926 | todos: { 927 | "": [todo_id], 928 | "#{todo_id}": [subtodo_id] 929 | }, 930 | archived: {}, 931 | metadata: { 932 | lastId: subtodo_id, 933 | missingIds: [] 934 | } 935 | }) 936 | index_path.open("wb") { |file| file.puts(index_json) } 937 | end 938 | end 939 | 940 | shared_context "when index file includes ID which todo file doesn't exist" do 941 | before do 942 | index_json = JSON.pretty_generate({ 943 | todos: { 944 | "": [todo_id, unknown_todo_id], 945 | "#{todo_id}": [subtodo_id] 946 | }, 947 | archived: {}, 948 | metadata: { 949 | lastId: subtodo_id, 950 | missingIds: [] 951 | } 952 | }) 953 | index_path.open("wb") { |file| file.puts(index_json) } 954 | end 955 | end 956 | 957 | context "when both parent todo and subtodo is undone" do 958 | include_context "when parent todo is undone" 959 | include_context "when subtodo is undone" 960 | include_context "when index file is normal" 961 | 962 | it "doesn't move parent todo file and subtodo file" do 963 | expect { repository.archive } 964 | .to not_change { todo_path.exist? } 965 | .and not_change { subtodo_path.exist? } 966 | end 967 | 968 | it "doesn't update index file" do 969 | expect { repository.archive }.not_to change { JSON.parse(index_path.read, symbolize_names: true) } 970 | end 971 | end 972 | 973 | context "when parent todo is done but subtodo is undone" do 974 | include_context "when parent todo is done" 975 | include_context "when subtodo is undone" 976 | include_context "when index file is normal" 977 | 978 | it "moves both parent todo file and subtodo file into archived directory" do 979 | expect { repository.archive } 980 | .to change { todo_path.exist? }.to(false) 981 | .and change { archived_todo_path.exist? }.to(true) 982 | .and change { subtodo_path.exist? }.to(false) 983 | .and change { archived_subtodo_path.exist? }.to(true) 984 | end 985 | 986 | it "updates index file" do 987 | expect { repository.archive }.to change { JSON.parse(index_path.read, symbolize_names: true) }.to({ 988 | todos: {}, 989 | archived: { 990 | "": [todo_id], 991 | "#{todo_id}": [subtodo_id] 992 | }, 993 | metadata: { 994 | lastId: subtodo_id, 995 | missingIds: [] 996 | } 997 | }) 998 | end 999 | end 1000 | 1001 | context "when parent todo is undone but subtodo is done" do 1002 | include_context "when parent todo is undone" 1003 | include_context "when subtodo is done" 1004 | include_context "when index file is normal" 1005 | 1006 | it "moves only subtodo file into archived directory" do 1007 | expect { repository.archive } 1008 | .to not_change { todo_path.exist? } 1009 | .and change { subtodo_path.exist? }.to(false) 1010 | .and change { archived_subtodo_path.exist? }.to(true) 1011 | end 1012 | 1013 | it "updates index file" do 1014 | expect { repository.archive }.to change { JSON.parse(index_path.read, symbolize_names: true) }.to({ 1015 | todos: { 1016 | "": [todo_id] 1017 | }, 1018 | archived: { 1019 | "#{todo_id}": [subtodo_id] 1020 | }, 1021 | metadata: { 1022 | lastId: subtodo_id, 1023 | missingIds: [] 1024 | } 1025 | }) 1026 | end 1027 | end 1028 | 1029 | context "when both parent todo and subtodo is done" do 1030 | include_context "when parent todo is done" 1031 | include_context "when subtodo is done" 1032 | include_context "when index file is normal" 1033 | 1034 | it "moves both parent todo file and subtodo file into archived directory" do 1035 | expect { repository.archive } 1036 | .to change { todo_path.exist? }.to(false) 1037 | .and change { archived_todo_path.exist? }.to(true) 1038 | .and change { subtodo_path.exist? }.to(false) 1039 | .and change { archived_subtodo_path.exist? }.to(true) 1040 | end 1041 | 1042 | it "updates index file" do 1043 | expect { repository.archive }.to change { JSON.parse(index_path.read, symbolize_names: true) }.to({ 1044 | todos: {}, 1045 | archived: { 1046 | "": [todo_id], 1047 | "#{todo_id}": [subtodo_id] 1048 | }, 1049 | metadata: { 1050 | lastId: subtodo_id, 1051 | missingIds: [] 1052 | } 1053 | }) 1054 | end 1055 | end 1056 | 1057 | context "when index file includes ID which todo file doesn't exist" do 1058 | include_context "when parent todo is done" 1059 | include_context "when subtodo is done" 1060 | include_context "when index file includes ID which todo file doesn't exist" 1061 | 1062 | it "puts error message to error output" do 1063 | repository.archive 1064 | 1065 | unknown_todo_path = Pathname.pwd.join("#{unknown_todo_id}.md") 1066 | expect(error_output.string).to eq("todo file is not found: #{unknown_todo_path}\n") 1067 | end 1068 | end 1069 | end 1070 | 1071 | describe "#move" do 1072 | let!(:repository) do 1073 | Todo::FileRepository.new( 1074 | root_path: Pathname.pwd, 1075 | opener: "open", 1076 | error_output: error_output, 1077 | ) 1078 | end 1079 | 1080 | before do 1081 | index_path.open("wb") do |file| 1082 | file.puts(JSON.pretty_generate({ 1083 | todos: { 1084 | "": [1, 2, 3], 1085 | "1": [4] 1086 | }, 1087 | archived: { 1088 | "": [5], 1089 | "5": [6] 1090 | }, 1091 | metadata: { 1092 | lastId: 6, 1093 | missingIds: [] 1094 | } 1095 | })) 1096 | end 1097 | 1098 | 1.upto(6) do |id| 1099 | todo_path = Pathname.pwd.join("#{id}.md") 1100 | todo_path.open("wb") do |file| 1101 | file.puts <<~TEXT 1102 | --- 1103 | title: dummy 1104 | state: done 1105 | --- 1106 | 1107 | body 1108 | TEXT 1109 | end 1110 | end 1111 | end 1112 | 1113 | [ 1114 | [[2, nil, -1], {todos: {"": [1, 3, 2], "1": [4]}}], 1115 | [[2, nil, 1], {todos: {"": [2, 1, 3], "1": [4]}}], 1116 | [[2, nil, 3], {todos: {"": [1, 3, 2], "1": [4]}}], 1117 | [[2, nil, 4], {todos: {"": [1, 3, 2], "1": [4]}}], 1118 | [[4, nil, 4], {todos: {"": [1, 2, 3, 4]}}], 1119 | [[2, 1, 1], {todos: {"": [1, 3], "1": [2, 4]}}], 1120 | [[2, 3, 1], {todos: {"": [1, 3], "1": [4], "3": [2]}}] 1121 | ].each do |(id, parent_id, position), expected_index| 1122 | context "when id: #{id}, parent_id: #{parent_id}, position: #{position}" do 1123 | it "updates index file" do 1124 | expect { 1125 | repository.move(id: id, parent_id: parent_id, position: position) 1126 | }.to change { 1127 | JSON.parse(index_path.read, symbolize_names: true) 1128 | }.to(a_hash_including(expected_index)) 1129 | end 1130 | end 1131 | end 1132 | 1133 | [ 1134 | [[100, nil, 1], "todo is not found: 100"], 1135 | [[1, 100, 1], "parent is not found: 100"], 1136 | [[1, 1, 1], "moving a todo under itself is forbidden"], 1137 | [[5, nil, 1], "moving an archived todo is forbidden"], 1138 | [[1, 6, 1], "moving a todo under an archived todo is forbidden"] 1139 | ].each do |(id, parent_id, position), error_message| 1140 | context "when id: #{id}, parent_id: #{parent_id}, position: #{position}" do 1141 | it "puts error message to error output" do 1142 | repository.move(id: id, parent_id: parent_id, position: position) 1143 | expect(error_output.string).to eq(error_message + "\n") 1144 | end 1145 | end 1146 | end 1147 | end 1148 | 1149 | describe "#open" do 1150 | let!(:repository) do 1151 | Todo::FileRepository.new( 1152 | root_path: Pathname.pwd, 1153 | opener: "open", 1154 | error_output: error_output, 1155 | ) 1156 | end 1157 | 1158 | context "when a todo with given ID exists" do 1159 | let(:id) { 1 } 1160 | let(:todo_path) { Pathname.pwd.join("#{id}.md") } 1161 | 1162 | before do 1163 | FileUtils.touch(todo_path) 1164 | end 1165 | 1166 | it "calls Kernel#.system with `open path/to/todo_file.md`" do 1167 | expect(repository).to receive(:system).with("open #{todo_path}") 1168 | repository.open(id: id) 1169 | end 1170 | end 1171 | 1172 | context "when a todo with given ID exists and is archived" do 1173 | let(:id) { 1 } 1174 | let(:todo_path) { archived_path.join("1.md") } 1175 | 1176 | before do 1177 | FileUtils.touch(todo_path) 1178 | end 1179 | 1180 | it "calls Kernel#.system with `open path/to/archived/todo_file.md`" do 1181 | expect(repository).to receive(:system).with("open #{todo_path}") 1182 | repository.open(id: id) 1183 | end 1184 | end 1185 | 1186 | context "when a todo with given ID doesn't exist" do 1187 | let(:id) { 1 } 1188 | 1189 | it "puts error message to error output" do 1190 | repository.open(id: id) 1191 | expect(error_output.string).to eq("todo is not found: #{id}\n") 1192 | end 1193 | end 1194 | end 1195 | end 1196 | -------------------------------------------------------------------------------- /spec/todo/todo_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe Todo::Todo do 4 | describe "#should_be_archived?" do 5 | subject { todo.should_be_archived? } 6 | 7 | let(:parent) do 8 | Todo::Todo.new( 9 | id: 1, 10 | title: "dummy", 11 | state: parent_state, 12 | body: "body", 13 | subtodos: [] 14 | ) 15 | end 16 | 17 | let(:todo) do 18 | Todo::Todo.new( 19 | id: 2, 20 | title: "dummy", 21 | state: state, 22 | body: "body", 23 | subtodos: [] 24 | ) 25 | end 26 | 27 | let(:parent_state) { :undone } 28 | let(:state) { :undone } 29 | 30 | shared_context "when parent is nil" do 31 | before do 32 | parent.subtodos = [] 33 | todo.parent = nil 34 | end 35 | end 36 | 37 | shared_context "when parent is present" do 38 | before do 39 | parent.subtodos << todo 40 | todo.parent = parent 41 | end 42 | end 43 | 44 | shared_context "when parent is undone" do 45 | let!(:parent_state) { :undone } 46 | end 47 | 48 | shared_context "when parent is done" do 49 | let!(:parent_state) { :done } 50 | end 51 | 52 | shared_context "when todo is undone" do 53 | let!(:state) { :undone } 54 | end 55 | 56 | shared_context "when todo is done" do 57 | let!(:state) { :done } 58 | end 59 | 60 | context "when parent is nil and todo is undone" do 61 | include_context "when parent is nil" 62 | include_context "when todo is undone" 63 | 64 | it { is_expected.to be false } 65 | end 66 | 67 | context "when parent is nil and todo is done" do 68 | include_context "when parent is nil" 69 | include_context "when todo is done" 70 | 71 | it { is_expected.to be true } 72 | end 73 | 74 | context "when parent is undone and todo is undone" do 75 | include_context "when parent is present" 76 | include_context "when parent is undone" 77 | include_context "when todo is undone" 78 | 79 | it { is_expected.to be false } 80 | end 81 | 82 | context "when parent is done and todo is undone" do 83 | include_context "when parent is present" 84 | include_context "when parent is done" 85 | include_context "when todo is undone" 86 | 87 | it { is_expected.to be true } 88 | end 89 | 90 | context "when parent is undone and todo is done" do 91 | include_context "when parent is present" 92 | include_context "when parent is undone" 93 | include_context "when todo is done" 94 | 95 | it { is_expected.to be true } 96 | end 97 | 98 | context "when parent is done and todo is done" do 99 | include_context "when parent is present" 100 | include_context "when parent is done" 101 | include_context "when todo is done" 102 | 103 | it { is_expected.to be true } 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /todo.gemspec: -------------------------------------------------------------------------------- 1 | require "./lib/todo" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "todo" 5 | spec.version = Todo::VERSION 6 | spec.license = "MIT" 7 | spec.summary = "My task manager" 8 | spec.author = "Naoto Kaneko" 9 | spec.email = "naoty.k@gmail.com" 10 | spec.files = Dir["lib/**/*.rb"] + Dir["bin/*"] 11 | spec.executable = "todo" 12 | spec.homepage = "https://github.com/naoty/todo" 13 | 14 | spec.add_development_dependency "rspec", "~> 3.10" 15 | spec.add_development_dependency "simplecov", "~> 0.21" 16 | spec.add_development_dependency "standard", "~> 1.1" 17 | end 18 | -------------------------------------------------------------------------------- /usage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naoty/todo/61e74c0f39ae987232c3b58c0c0700d765183168/usage.png --------------------------------------------------------------------------------