rfc-reader 1.0.0 → 1.1.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7b42f53e0145a5153d21e1f555c006e2081c43c126b60c581f5687567530f00b
4
- data.tar.gz: cb3d545f31ebba48757242e003e9f3ef2d9960c1a6277256cfc6dfcc389af38c
3
+ metadata.gz: cc7f0a4142e7e5cb5b1ed4b8a5ecda4728177df15e6f9c8d7e84dcc557b660e4
4
+ data.tar.gz: 89e7ac7472c84e84d87feed86e66f35f14dbc65a7af39e6a55fe65e158b78e89
5
5
  SHA512:
6
- metadata.gz: 3337413fc3634c92bf8ec4ff3f347749de4b4cff385e6b29184fa63a641e8456f9cfd21142e07d125b09e320aa38bbab57e90c7f64cc2f75d516c7fa3307d81a
7
- data.tar.gz: 557038fda98a3ea623ea128cd99aa4423ff2befb1493f4f0e7313807a60273bf2c14007df1d818b90ce5e33bdd6de08a8d5814e69227104ede1eaa21cb4349e8
6
+ metadata.gz: 0b0588ecb6c225fa8d2ef6c1c27506dda70d59ae2af050797f6ed71536a379423848570022223b04f0911ca3d987431d2b28ab4c019c8c74c31ece1fed3e037a
7
+ data.tar.gz: 51e9100f05269801e8fcbbe879405181ae0d3910e42593d5a9ad5933d6d8f3f45214cc8a8827d809b75e920029ccfd7584139c707e6a9e3fe0ec12ca750c4f77
data/exe/rfc-reader CHANGED
@@ -4,4 +4,4 @@
4
4
  $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
5
  require "rfc_reader"
6
6
 
7
- RfcReader::Command.start
7
+ exit RfcReader.start
@@ -4,10 +4,17 @@ require "thor"
4
4
 
5
5
  module RfcReader
6
6
  class Command < Thor
7
+ SUCCESS = 0
8
+ private_constant :SUCCESS
9
+
10
+ FAILURE = 1
11
+ private_constant :FAILURE
12
+
7
13
  # @return [Boolean]
8
14
  def self.exit_on_failure? = true
9
15
 
10
16
  # @param command [String, nil]
17
+ # @return [Integer] exit code
11
18
  def help(command = nil)
12
19
  unless command
13
20
  puts <<~DESCRIPTION
@@ -23,6 +30,8 @@ module RfcReader
23
30
  end
24
31
 
25
32
  super
33
+
34
+ SUCCESS
26
35
  end
27
36
 
28
37
  desc "search [TERM]", "Search for RFCs by TERM for reading"
@@ -33,12 +42,18 @@ module RfcReader
33
42
  it will get downloaded so that you can read it in your terminal.
34
43
  LONGDESC
35
44
  # @param term [String]
45
+ # @return [Integer] exit code
36
46
  def search(term)
37
47
  search_results = Search.search_by(term: term)
48
+ if search_results.empty?
49
+ warn "No search results for: #{term}"
50
+ return FAILURE
51
+ end
38
52
  title = Terminal.choose("Choose an RFC to read:", search_results.keys)
39
53
  url = search_results.fetch(title)
40
54
  content = Library.download_document(title: title, url: url)
41
55
  Terminal.page(content)
56
+ SUCCESS
42
57
  end
43
58
 
44
59
  desc "recent", "List recent RFC releases for reading"
@@ -48,12 +63,18 @@ module RfcReader
48
63
  Choose any RFC from the list that seems interesting and
49
64
  it will get downloaded so that you can read it in your terminal.
50
65
  LONGDESC
66
+ # @return [Integer] exit code
51
67
  def recent
52
68
  recent_results = Recent.list
69
+ if recent_results.empty?
70
+ warn "Error: Empty recent RFC list from rfc-editor.org RSS feed"
71
+ return FAILURE
72
+ end
53
73
  title = Terminal.choose("Choose an RFC to read:", recent_results.keys)
54
74
  url = recent_results.fetch(title)
55
75
  content = Library.download_document(title: title, url: url)
56
76
  Terminal.page(content)
77
+ SUCCESS
57
78
  end
58
79
 
59
80
  desc "library", "List already downloaded RFCs for reading"
@@ -63,18 +84,29 @@ module RfcReader
63
84
  Choose any RFC from the list that seems interesting and
64
85
  you can read it offline in your terminal.
65
86
  LONGDESC
87
+ # @return [Integer] exit code
66
88
  def library
67
89
  rfc_catalog = Library.catalog
90
+ if rfc_catalog.empty?
91
+ warn <<~MESSAGE
92
+ No RFCs are currently saved in the library. Try using the
93
+ `search` or `recent` commands to download some RFCs first.
94
+ MESSAGE
95
+ return FAILURE
96
+ end
68
97
  all_titles = rfc_catalog.map { _1[:title] }
69
98
  title = Terminal.choose("Choose an RFC to read:", all_titles)
70
99
  rfc = rfc_catalog.find { _1[:title] == title }
71
100
  content = Library.load_document(**rfc)
72
101
  Terminal.page(content)
102
+ SUCCESS
73
103
  end
74
104
 
75
105
  desc "version", "Print the program version"
106
+ # @return [Integer] exit code
76
107
  def version
77
108
  puts VERSION
109
+ SUCCESS
78
110
  end
79
111
  end
80
112
  end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module RfcReader
6
+ module ErrorContext
7
+ class ContextError < StandardError
8
+ extend Forwardable
9
+
10
+ attr_reader :context
11
+
12
+ def_delegators :@cause, :message, :to_s, :backtrace, :backtrace_locations
13
+
14
+ # @param cause [StandardError]
15
+ # @param context [String] (Optional)
16
+ def initialize(cause:, context: nil)
17
+ @cause = cause
18
+ @context = context || default_context
19
+ super
20
+ end
21
+
22
+ def short_message
23
+ "Error: #{context}"
24
+ end
25
+
26
+ def full_message
27
+ <<~MESSAGE
28
+ Context:
29
+ #{indent(@context)}
30
+ Error:
31
+ #{@cause.class}
32
+ Message:
33
+ #{indent(message)}
34
+ Backtrace:
35
+ #{indent(backtrace)}
36
+ MESSAGE
37
+ end
38
+
39
+ private
40
+
41
+ def default_context
42
+ "#{@cause.class}: #{message.lines.first}"
43
+ end
44
+
45
+ def indent(string_or_array)
46
+ strings = case string_or_array
47
+ when Array then string_or_array
48
+ when String then string_or_array.lines
49
+ else raise ArgumentError, "Expected string or array instead of #{string_or_array.class}"
50
+ end
51
+
52
+ strings
53
+ .map { _1.prepend(" ") }
54
+ .join("\n")
55
+ end
56
+ end
57
+
58
+ # Yields an error context. Any `StandardError` that gets raised in this block
59
+ # gets wrapped by `ContextError` automatically and re-raised.
60
+ #
61
+ # If no error has been raised, it returns the result of the block.
62
+ #
63
+ # @param context [String]
64
+ # @return yielded block value
65
+ def self.wrap(context)
66
+ yield
67
+ rescue StandardError => e
68
+ raise ContextError.new(cause: e, context: context)
69
+ end
70
+
71
+ # Yields a handler context where any `StandardError` is caught and the error message is
72
+ # printed to stderr along with the context. It prints only a short message by default
73
+ # and prints the full error message if the `DEBUG` error message is set. It then exits
74
+ # with a non-zero error code.
75
+ #
76
+ # If no error has been raised, it returns the result of the block as an integer.
77
+ # If the result of block cannot be turned into an integer, it returns zero.
78
+ #
79
+ # @return [Integer] exit code
80
+ def self.handler
81
+ error = nil
82
+
83
+ begin
84
+ result = yield
85
+ return result.respond_to?(:to_i) ? result.to_i : 0
86
+ rescue ContextError => e
87
+ error = e
88
+ rescue StandardError => e
89
+ error = ContextError.new(cause: e)
90
+ end
91
+
92
+ if ENV["DEBUG"]
93
+ warn error.full_message
94
+ else
95
+ warn error.short_message
96
+ warn "Note: Set the `DEBUG` environment variable to see the full error context"
97
+ end
98
+
99
+ 1
100
+ end
101
+ end
102
+ end
@@ -16,7 +16,10 @@ module RfcReader
16
16
  file_name = File.basename(url)
17
17
  file_path = File.join(library_cache_dir, file_name)
18
18
 
19
- content = Net::HTTP.get(URI(url))
19
+ content = ErrorContext.wrap("Downloading RFC document") do
20
+ Net::HTTP.get(URI(url))
21
+ end
22
+
20
23
  FileUtils.mkdir_p(library_cache_dir)
21
24
  File.write(file_path, content)
22
25
  add_to_catalog(title: title, url: url, path: file_path)
@@ -16,7 +16,9 @@ module RfcReader
16
16
 
17
17
  # @return [String] the raw XML from the recent RFCs RSS feed
18
18
  def self.fetch
19
- Net::HTTP.get(RECENT_RFCS_RSS_URI)
19
+ ErrorContext.wrap("Fetching the recent RFCs list") do
20
+ Net::HTTP.get(RECENT_RFCS_RSS_URI)
21
+ end
20
22
  end
21
23
 
22
24
  # Example: XML fragment we're trying to parse title and link data from.
@@ -36,18 +38,20 @@ module RfcReader
36
38
  # @param xml [String] the XML of the recent RFCs RSS endpoint
37
39
  # @return [Hash<String, String>] from RFC title to text file url
38
40
  def self.parse(xml)
39
- Nokogiri::XML(xml).xpath("//item").to_h do |item|
40
- item_hash = item.elements.to_h do |elem|
41
- [elem.name, elem.text.strip]
42
- end
41
+ ErrorContext.wrap("Parsing the recent RFCs list") do
42
+ Nokogiri::XML(xml).xpath("//item").to_h do |item|
43
+ item_hash = item.elements.to_h do |elem|
44
+ [elem.name, elem.text.strip]
45
+ end
43
46
 
44
- # The link is to the webpage and not the plaintext document so we must convert it.
45
- file_name = File.basename(item_hash.fetch("link"))
47
+ # The link is to the webpage and not the plaintext document so we must convert it.
48
+ file_name = File.basename(item_hash.fetch("link"))
46
49
 
47
- [
48
- item_hash.fetch("title"),
49
- "https://www.rfc-editor.org/rfc/#{file_name}.txt",
50
- ]
50
+ [
51
+ item_hash.fetch("title"),
52
+ "https://www.rfc-editor.org/rfc/#{file_name}.txt",
53
+ ]
54
+ end
51
55
  end
52
56
  end
53
57
  end
@@ -18,7 +18,9 @@ module RfcReader
18
18
  # @param term [String]
19
19
  # @return [String] the raw HTML of the search results for the given term
20
20
  def self.fetch_by(term:)
21
- Net::HTTP.post_form(RFC_SEARCH_URI, { combo_box: term }).body
21
+ ErrorContext.wrap("Fetching RFC search results") do
22
+ Net::HTTP.post_form(RFC_SEARCH_URI, { combo_box: term }).body
23
+ end
22
24
  end
23
25
 
24
26
  # Example: HTML fragment we're trying to parse title and link info from.
@@ -71,13 +73,23 @@ module RfcReader
71
73
  # @param html [String] the HTML of the search results
72
74
  # @return [Hash<String, String>] from RFC title to text file url
73
75
  def self.parse(html)
74
- # NOTE: The first element in the table is just some general search information. See example HTML above.
75
- Nokogiri::HTML(html).xpath("//div[@class='scrolltable']//table[@class='gridtable']//tr").drop(1).to_h do |tr_node|
76
- td_nodes = tr_node.elements
77
- title = td_nodes[2].text.strip
78
- url = td_nodes[1].elements.map { _1.attribute("href").text.strip }.find { _1.end_with?(".txt") }
76
+ ErrorContext.wrap("Parsing RFC search results") do
77
+ # NOTE: The first element in the table is just some general search information. See example HTML above.
78
+ Nokogiri::HTML(html)
79
+ .xpath("//div[@class='scrolltable']//table[@class='gridtable']//tr")
80
+ .drop(1)
81
+ .to_h do |tr_node|
82
+ td_nodes = tr_node.elements
83
+ title = td_nodes[2]
84
+ .text
85
+ .strip
86
+ url = td_nodes[1]
87
+ .elements
88
+ .map { _1.attribute("href").text.strip }
89
+ .find { _1.end_with?(".txt") }
79
90
 
80
- [title, url]
91
+ [title, url]
92
+ end
81
93
  end
82
94
  end
83
95
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RfcReader
4
- VERSION = "1.0.0"
4
+ VERSION = "1.1.1"
5
5
  end
data/lib/rfc_reader.rb CHANGED
@@ -8,4 +8,12 @@ module RfcReader
8
8
  autoload :Search, "rfc_reader/search"
9
9
  autoload :Terminal, "rfc_reader/terminal"
10
10
  autoload :Library, "rfc_reader/library"
11
+ autoload :ErrorContext, "rfc_reader/error_context"
12
+
13
+ # @return [Integer] exit code
14
+ def self.start
15
+ ErrorContext.handler do
16
+ Command.start
17
+ end
18
+ end
11
19
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rfc-reader
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kevin Robell
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-09-24 00:00:00.000000000 Z
11
+ date: 2024-10-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: nokogiri
@@ -77,6 +77,7 @@ files:
77
77
  - exe/rfc-reader
78
78
  - lib/rfc_reader.rb
79
79
  - lib/rfc_reader/command.rb
80
+ - lib/rfc_reader/error_context.rb
80
81
  - lib/rfc_reader/library.rb
81
82
  - lib/rfc_reader/recent.rb
82
83
  - lib/rfc_reader/search.rb