archsight 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +33 -0
  3. data/chart/archsight/Chart.yaml +6 -0
  4. data/chart/archsight/README.md +160 -0
  5. data/chart/archsight/templates/NOTES.txt +22 -0
  6. data/chart/archsight/templates/_helpers.tpl +62 -0
  7. data/chart/archsight/templates/deployment.yaml +114 -0
  8. data/chart/archsight/templates/ingress.yaml +56 -0
  9. data/chart/archsight/templates/resources-configmap.yaml +10 -0
  10. data/chart/archsight/templates/resources-pvc.yaml +23 -0
  11. data/chart/archsight/templates/service.yaml +15 -0
  12. data/chart/archsight/templates/serviceaccount.yaml +12 -0
  13. data/chart/archsight/values.yaml +162 -0
  14. data/lib/archsight/analysis/executor.rb +0 -10
  15. data/lib/archsight/annotations/annotation.rb +85 -36
  16. data/lib/archsight/annotations/architecture_annotations.rb +1 -34
  17. data/lib/archsight/annotations/computed.rb +1 -1
  18. data/lib/archsight/annotations/generated_annotations.rb +6 -3
  19. data/lib/archsight/annotations/git_annotations.rb +8 -4
  20. data/lib/archsight/annotations/interface_annotations.rb +35 -0
  21. data/lib/archsight/cli.rb +3 -1
  22. data/lib/archsight/editor/content_hasher.rb +37 -0
  23. data/lib/archsight/editor/file_writer.rb +79 -0
  24. data/lib/archsight/editor.rb +237 -0
  25. data/lib/archsight/import/handlers/github.rb +14 -6
  26. data/lib/archsight/import/handlers/gitlab.rb +14 -6
  27. data/lib/archsight/import/handlers/repository.rb +3 -1
  28. data/lib/archsight/import/team_matcher.rb +111 -61
  29. data/lib/archsight/mcp/execute_analysis_tool.rb +100 -0
  30. data/lib/archsight/mcp.rb +1 -0
  31. data/lib/archsight/resources/analysis.rb +1 -17
  32. data/lib/archsight/resources/application_interface.rb +1 -5
  33. data/lib/archsight/resources/base.rb +14 -14
  34. data/lib/archsight/resources/business_actor.rb +1 -1
  35. data/lib/archsight/resources/technology_interface.rb +1 -1
  36. data/lib/archsight/resources/technology_service.rb +5 -0
  37. data/lib/archsight/version.rb +1 -1
  38. data/lib/archsight/web/application.rb +8 -0
  39. data/lib/archsight/web/doc/import.md +10 -2
  40. data/lib/archsight/web/editor/form_builder.rb +100 -0
  41. data/lib/archsight/web/editor/routes.rb +293 -0
  42. data/lib/archsight/web/public/css/editor.css +863 -0
  43. data/lib/archsight/web/public/css/instance.css +6 -0
  44. data/lib/archsight/web/public/js/editor.js +421 -0
  45. data/lib/archsight/web/public/js/lexical-editor.js +308 -0
  46. data/lib/archsight/web/views/partials/editor/_field.haml +80 -0
  47. data/lib/archsight/web/views/partials/editor/_form.haml +131 -0
  48. data/lib/archsight/web/views/partials/editor/_relations.haml +39 -0
  49. data/lib/archsight/web/views/partials/editor/_yaml_output.haml +33 -0
  50. data/lib/archsight/web/views/partials/instance/_analysis_detail.haml +4 -11
  51. data/lib/archsight/web/views/partials/instance/_detail.haml +4 -0
  52. data/lib/archsight/web/views/partials/layout/_content.haml +8 -2
  53. data/lib/archsight/web/views/partials/layout/_head.haml +2 -0
  54. metadata +26 -1
@@ -0,0 +1,162 @@
1
+ replicaCount: 1
2
+
3
+ image:
4
+ repository: ghcr.io/ionos-cloud/archsight
5
+ pullPolicy: IfNotPresent
6
+ # Overrides the image tag whose default is the chart appVersion.
7
+ tag: ""
8
+
9
+ imagePullSecrets: []
10
+ nameOverride: ""
11
+ fullnameOverride: ""
12
+
13
+ serviceAccount:
14
+ # Specifies whether a service account should be created
15
+ create: true
16
+ # Annotations to add to the service account
17
+ annotations: {}
18
+ # The name of the service account to use.
19
+ # If not set and create is true, a name is generated using the fullname template
20
+ name: ""
21
+
22
+ podAnnotations: {}
23
+
24
+ podSecurityContext: {}
25
+ # fsGroup: 2000
26
+
27
+ securityContext: {}
28
+ # capabilities:
29
+ # drop:
30
+ # - ALL
31
+ # readOnlyRootFilesystem: true
32
+ # runAsNonRoot: true
33
+ # runAsUser: 1000
34
+
35
+ service:
36
+ type: ClusterIP
37
+ port: 80
38
+ targetPort: 4567
39
+
40
+ ingress:
41
+ enabled: false
42
+ className: ""
43
+ annotations: {}
44
+ # kubernetes.io/ingress.class: nginx
45
+ # kubernetes.io/tls-acme: "true"
46
+ hosts:
47
+ - host: chart-example.local
48
+ paths:
49
+ - path: /
50
+ pathType: ImplementationSpecific
51
+ tls: []
52
+ # - secretName: chart-example-tls
53
+ # hosts:
54
+ # - chart-example.local
55
+
56
+ resources: {}
57
+ # limits:
58
+ # cpu: 100m
59
+ # memory: 128Mi
60
+ # requests:
61
+ # cpu: 100m
62
+ # memory: 128Mi
63
+
64
+ autoscaling:
65
+ enabled: false
66
+ minReplicas: 1
67
+ maxReplicas: 100
68
+ targetCPUUtilizationPercentage: 80
69
+ # targetMemoryUtilizationPercentage: 80
70
+
71
+ nodeSelector: {}
72
+
73
+ tolerations: []
74
+
75
+ affinity: {}
76
+
77
+ # Application specific configuration
78
+ app:
79
+ resourcesDir: /resources
80
+ env: production
81
+
82
+ # Strategy for populating the /resources directory
83
+ # Options are "emptyDir", "configMap", "persistence" (PVC), or usage of "extraVolumes"
84
+ content:
85
+ # type: "emptyDir" | "configMap" | "persistence" | "custom"
86
+ # - emptyDir: Simple, ephemeral. Good if using initContainers to fetch data.
87
+ # - configMap: Mounts a ConfigMap. Good for small, static datasets.
88
+ # - persistence: Mounts a PersistentVolumeClaim. Good for large datasets.
89
+ # - custom: Do not create the main volume automatically; rely on extraVolumes/extraVolumeMounts.
90
+ type: emptyDir
91
+
92
+ configMap:
93
+ # If true, creating a ConfigMap from the `data` below.
94
+ create: false
95
+ # Name of the ConfigMap to mount (if create: false) or create (if create: true)
96
+ # If create: true and name is empty, fullname + "-resources" is used.
97
+ name: ""
98
+ # Data to inject into the ConfigMap (filename: content)
99
+ data:
100
+ # sample.yaml: |
101
+ # apiVersion: architecture/v1alpha1
102
+ # kind: Analysis
103
+ # ...
104
+ {}
105
+
106
+ persistence:
107
+ enabled: false
108
+ # existingClaim: my-claim
109
+ storageClass: ""
110
+ accessModes:
111
+ - ReadWriteOnce
112
+ size: 1Gi
113
+ annotations: {}
114
+
115
+ # Add sidecars or initContainers (e.g., for git-sync)
116
+ initContainers: []
117
+ # Example: Fetching from a private repository using a specific Secret
118
+ # - name: git-sync
119
+ # image: alpine/git
120
+ # env:
121
+ # - name: GIT_TOKEN
122
+ # valueFrom:
123
+ # secretKeyRef:
124
+ # name: my-git-credentials
125
+ # key: token
126
+ # command: ["/bin/sh", "-c"]
127
+ # args:
128
+ # - "git clone https://oauth2:$GIT_TOKEN@github.com/my-org/my-private-repo.git /resources"
129
+ # volumeMounts:
130
+ # - name: resources
131
+ # mountPath: /resources
132
+
133
+ sidecars: []
134
+ # Example: Periodically update resources from a private repository
135
+ # - name: git-sync-private
136
+ # image: alpine/git
137
+ # env:
138
+ # - name: GIT_TOKEN
139
+ # valueFrom:
140
+ # secretKeyRef:
141
+ # name: my-git-credentials
142
+ # key: token
143
+ # command: ["/bin/sh", "-c"]
144
+ # args:
145
+ # - |
146
+ # cd /resources
147
+ # # Ensure the remote uses the current token
148
+ # git remote set-url origin "https://oauth2:${GIT_TOKEN}@github.com/my-org/my-private-repo.git"
149
+ # while true; do
150
+ # git pull
151
+ # sleep 60
152
+ # done
153
+ # volumeMounts:
154
+ # - name: resources
155
+ # mountPath: /resources
156
+
157
+ # Custom volumes and mounts if you need more control
158
+ extraVolumes: []
159
+ extraVolumeMounts: []
160
+
161
+ # Command arguments (passed to ENTRYPOINT ["archsight"])
162
+ args: ["web", "--port", "4567", "-H", "0.0.0.0", "--production"]
@@ -79,9 +79,6 @@ module Archsight
79
79
  # Filter by name pattern if provided
80
80
  analyses = analyses.select { |a| filter.match?(a.name) } if filter
81
81
 
82
- # Filter to enabled analyses
83
- analyses = analyses.select { |a| analysis_enabled?(a) }
84
-
85
82
  analyses.map { |analysis| execute(analysis) }
86
83
  end
87
84
 
@@ -100,13 +97,6 @@ module Archsight
100
97
 
101
98
  DEFAULT_TIMEOUT
102
99
  end
103
-
104
- # Check if analysis is enabled
105
- # @param analysis [Object] Analysis instance
106
- # @return [Boolean] true if enabled
107
- def analysis_enabled?(analysis)
108
- analysis.annotations["analysis/enabled"] != "false"
109
- end
110
100
  end
111
101
  end
112
102
  end
@@ -4,7 +4,7 @@ require_relative "email_recipient"
4
4
 
5
5
  # Annotation represents a single annotation definition with its schema and behavior
6
6
  class Archsight::Annotations::Annotation
7
- attr_reader :key, :description, :filter, :format, :enum, :sidebar, :type, :list
7
+ attr_reader :key, :description, :filter, :format, :enum, :sidebar, :type, :list, :editor
8
8
 
9
9
  def initialize(key, options = {})
10
10
  @key = key
@@ -14,6 +14,7 @@ class Archsight::Annotations::Annotation
14
14
  @enum = options[:enum]
15
15
  @sidebar = options.fetch(:sidebar, true)
16
16
  @list = options.fetch(:list, false)
17
+ @editor = options.fetch(:editor, true)
17
18
  @type = options[:type]
18
19
 
19
20
  # Auto-add filter if enum present
@@ -80,43 +81,12 @@ class Archsight::Annotations::Annotation
80
81
 
81
82
  # Validate a value and return array of error messages (empty if valid)
82
83
  def validate(value)
83
- errors = []
84
+ errors = [] #: Array[String]
84
85
  return errors if value.nil?
85
86
 
86
- # Check enum constraint
87
- if @enum
88
- values = list? ? value.to_s.split(",").map(&:strip) : [value.to_s]
89
- invalid_values = values.reject { |v| @enum.include?(v) }
90
- invalid_values.each do |v|
91
- errors << "invalid value '#{v}'. Expected one of: #{@enum.join(", ")}"
92
- end
93
- end
94
-
95
- # Check type constraint
96
- if @type.is_a?(Class) && errors.empty?
97
- values_to_check = list? ? value.to_s.split(/,|\n/).map(&:strip).reject(&:empty?) : [value.to_s]
98
-
99
- values_to_check.each do |string_value|
100
- valid = case @type.to_s
101
- when "Integer"
102
- string_value.match?(/\A-?\d+\z/)
103
- when "Float"
104
- string_value.match?(/\A-?\d+(\.\d+)?\z/)
105
- when "URI"
106
- begin
107
- URI.parse(string_value)
108
- string_value.match?(%r{\Ahttps?://})
109
- rescue URI::InvalidURIError
110
- false
111
- end
112
- when "Archsight::Annotations::EmailRecipient"
113
- Archsight::Annotations::EmailRecipient.valid?(string_value)
114
- else
115
- true
116
- end
117
- errors << "invalid value '#{string_value}'. #{type_error_message}" unless valid
118
- end
119
- end
87
+ validate_enum(value, errors)
88
+ validate_type(value, errors) if errors.empty?
89
+ validate_code(value, errors) if errors.empty?
120
90
 
121
91
  errors
122
92
  end
@@ -130,6 +100,18 @@ class Archsight::Annotations::Annotation
130
100
  @format == :markdown
131
101
  end
132
102
 
103
+ def code?
104
+ @format == :ruby
105
+ end
106
+
107
+ def multiline?
108
+ @format == :multiline
109
+ end
110
+
111
+ def code_language
112
+ @format if code?
113
+ end
114
+
133
115
  # Example value for templates
134
116
  def example_value
135
117
  if @enum
@@ -155,6 +137,49 @@ class Archsight::Annotations::Annotation
155
137
  end
156
138
  end
157
139
 
140
+ def validate_enum(value, errors)
141
+ return unless @enum
142
+
143
+ values = list? ? value.to_s.split(",").map(&:strip) : [value.to_s]
144
+ invalid_values = values.reject { |v| @enum.include?(v) }
145
+ invalid_values.each do |v|
146
+ errors << "invalid value '#{v}'. Expected one of: #{@enum.join(", ")}"
147
+ end
148
+ end
149
+
150
+ def validate_type(value, errors)
151
+ return unless @type.is_a?(Class)
152
+
153
+ values_to_check = list? ? value.to_s.split(/,|\n/).map(&:strip).reject(&:empty?) : [value.to_s]
154
+ values_to_check.each do |string_value|
155
+ errors << "invalid value '#{string_value}'. #{type_error_message}" unless valid_type_value?(string_value)
156
+ end
157
+ end
158
+
159
+ def valid_type_value?(string_value)
160
+ case @type.to_s
161
+ when "Integer" then string_value.match?(/\A-?\d+\z/)
162
+ when "Float" then string_value.match?(/\A-?\d+(\.\d+)?\z/)
163
+ when "URI" then valid_uri?(string_value)
164
+ when "Archsight::Annotations::EmailRecipient" then Archsight::Annotations::EmailRecipient.valid?(string_value)
165
+ else true
166
+ end
167
+ end
168
+
169
+ def valid_uri?(string_value)
170
+ URI.parse(string_value)
171
+ string_value.match?(%r{\Ahttps?://})
172
+ rescue URI::InvalidURIError
173
+ false
174
+ end
175
+
176
+ def validate_code(value, errors)
177
+ return unless code? && !value.to_s.strip.empty?
178
+
179
+ syntax_error = validate_code_syntax(value.to_s)
180
+ errors << syntax_error if syntax_error
181
+ end
182
+
158
183
  def derive_format
159
184
  case @filter
160
185
  when :word then :tag_word
@@ -165,4 +190,28 @@ class Archsight::Annotations::Annotation
165
190
  def build_regex
166
191
  Regexp.new("^#{Regexp.escape(key).gsub('\*', ".+")}$")
167
192
  end
193
+
194
+ # Validate code syntax based on format
195
+ # @param code [String] The code to validate
196
+ # @return [String, nil] Error message or nil if valid
197
+ def validate_code_syntax(code)
198
+ case @format
199
+ when :ruby
200
+ validate_ruby_syntax(code)
201
+ end
202
+ end
203
+
204
+ # Validate Ruby syntax using RubyVM
205
+ # @param code [String] Ruby code to validate
206
+ # @return [String, nil] Error message or nil if valid
207
+ def validate_ruby_syntax(code)
208
+ # steep:ignore:start
209
+ RubyVM::InstructionSequence.compile(code)
210
+ # steep:ignore:end
211
+ nil
212
+ rescue SyntaxError => e
213
+ # Extract just the error message without the full backtrace
214
+ message = e.message.lines.first&.strip || "Syntax error"
215
+ "Ruby syntax error: #{message}"
216
+ end
168
217
  end
@@ -9,12 +9,8 @@ module Archsight::Annotations::Architecture
9
9
  annotation "architecture/abbr",
10
10
  description: "Abbreviation or short name",
11
11
  title: "Abbreviation"
12
- annotation "architecture/evidence",
13
- description: "Supporting evidence or notes",
14
- title: "Evidence",
15
- format: :markdown
16
12
  annotation "architecture/description",
17
- description: "Textual description of the interface",
13
+ description: "Textual description of the resource",
18
14
  title: "Description",
19
15
  format: :markdown
20
16
  annotation "architecture/documentation",
@@ -25,35 +21,6 @@ module Archsight::Annotations::Architecture
25
21
  description: "Comma-separated tags",
26
22
  filter: :list,
27
23
  title: "Tags"
28
- annotation "architecture/encoding",
29
- description: "Data encoding format",
30
- filter: :list,
31
- title: "Encoding"
32
- annotation "architecture/title",
33
- description: "Interface title",
34
- title: "Title"
35
- annotation "architecture/openapi",
36
- description: "OpenAPI specification version",
37
- filter: :word,
38
- title: "OpenAPI"
39
- annotation "architecture/version",
40
- description: "API or interface version",
41
- filter: :word,
42
- title: "Version",
43
- sidebar: false
44
- annotation "architecture/status",
45
- description: "Lifecycle status (General-Availability, Early-Access, Development)",
46
- filter: :word,
47
- title: "Status"
48
- annotation "architecture/visibility",
49
- description: "API visibility (public, private, internal)",
50
- filter: :word,
51
- enum: %w[public private internal],
52
- title: "Visibility"
53
- annotation "architecture/applicationSets",
54
- description: "Related ArgoCD ApplicationSets",
55
- title: "ApplicationSets",
56
- format: :markdown
57
24
  end
58
25
  end
59
26
  end
@@ -212,7 +212,7 @@ class Archsight::Annotations::ComputedManager
212
212
  @computing.add(cache_key)
213
213
  begin
214
214
  evaluator = Archsight::Annotations::ComputedEvaluator.new(instance, @database, self)
215
- value = evaluator.instance_eval(&definition.block)
215
+ value = evaluator.instance_eval(&definition.block) # steep:ignore BlockTypeMismatch
216
216
 
217
217
  # Apply type coercion if specified
218
218
  value = coerce_value(value, definition.type) if definition.type
@@ -7,15 +7,18 @@ module Archsight::Annotations::Generated
7
7
  annotation "generated/script",
8
8
  description: "Name of the script that generated this resource",
9
9
  title: "Generated By",
10
- sidebar: false
10
+ sidebar: false,
11
+ editor: false
11
12
  annotation "generated/at",
12
13
  description: "Timestamp when this resource was generated (ISO8601)",
13
14
  title: "Generated At",
14
- sidebar: false
15
+ sidebar: false,
16
+ editor: false
15
17
  annotation "generated/configHash",
16
18
  description: "Hash of configuration used to generate this resource (for change detection)",
17
19
  title: "Config Hash",
18
- sidebar: false
20
+ sidebar: false,
21
+ editor: false
19
22
  end
20
23
  end
21
24
  end
@@ -6,16 +6,20 @@ module Archsight::Annotations::Git
6
6
  base.class_eval do
7
7
  annotation "git/updatedAt",
8
8
  description: "Date when the resource was last updated",
9
- title: "Updated At"
9
+ title: "Updated At",
10
+ editor: false
10
11
  annotation "git/updatedBy",
11
12
  description: "Email of person who last updated the resource",
12
- title: "Updated By"
13
+ title: "Updated By",
14
+ editor: false
13
15
  annotation "git/reviewedAt",
14
16
  description: "Date when the resource was last reviewed",
15
- title: "Reviewed At"
17
+ title: "Reviewed At",
18
+ editor: false
16
19
  annotation "git/reviewedBy",
17
20
  description: "Email of person who last reviewed the resource",
18
- title: "Reviewed By"
21
+ title: "Reviewed By",
22
+ editor: false
19
23
  end
20
24
  end
21
25
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Interface module adds interface-specific annotations to resource classes
4
+ module Archsight::Annotations::Interface
5
+ def self.included(base)
6
+ base.class_eval do
7
+ annotation "architecture/encoding",
8
+ description: "Data encoding format",
9
+ filter: :list,
10
+ title: "Encoding"
11
+ annotation "architecture/title",
12
+ description: "Interface title",
13
+ title: "Title"
14
+ annotation "architecture/openapi",
15
+ description: "OpenAPI specification version",
16
+ filter: :word,
17
+ title: "OpenAPI"
18
+ annotation "architecture/version",
19
+ description: "API or interface version",
20
+ filter: :word,
21
+ title: "Version",
22
+ sidebar: false
23
+ annotation "architecture/status",
24
+ description: "Lifecycle status",
25
+ filter: :word,
26
+ enum: %w[General-Availability Early-Access Development],
27
+ title: "Status"
28
+ annotation "architecture/visibility",
29
+ description: "API visibility (public, private, internal)",
30
+ filter: :word,
31
+ enum: %w[public private internal],
32
+ title: "Visibility"
33
+ end
34
+ end
35
+ end
data/lib/archsight/cli.rb CHANGED
@@ -19,6 +19,7 @@ module Archsight
19
19
  option :production, type: :boolean, default: false, desc: "Run in production mode"
20
20
  option :disable_reload, type: :boolean, default: false, desc: "Disable the reload button in the UI"
21
21
  option :enable_logging, type: :boolean, default: nil, desc: "Enable request logging (default: false in dev, true in prod)"
22
+ option :inline_edit, type: :boolean, default: false, desc: "Enable inline editing (save directly to source files)"
22
23
  def web
23
24
  configure_resources
24
25
  require "archsight/web/application"
@@ -26,6 +27,7 @@ module Archsight
26
27
  env = options[:production] ? :production : :development
27
28
  Archsight::Web::Application.configure_environment!(env, logging: options[:enable_logging])
28
29
  Archsight::Web::Application.set :reload_enabled, !options[:disable_reload]
30
+ Archsight::Web::Application.set :inline_edit_enabled, options[:inline_edit]
29
31
  Archsight::Web::Application.setup_mcp!
30
32
  Archsight::Web::Application.run!(port: options[:port], bind: options[:host])
31
33
  rescue Archsight::ResourceError => e
@@ -191,7 +193,7 @@ module Archsight
191
193
  def filter_analyses(db)
192
194
  analyses = db.instances_by_kind("Analysis").values
193
195
  analyses = analyses.select { |a| Regexp.new(options[:filter], Regexp::IGNORECASE).match?(a.name) } if options[:filter]
194
- analyses.reject { |a| a.annotations["analysis/enabled"] == "false" }
196
+ analyses
195
197
  end
196
198
 
197
199
  def print_analysis_dry_run(analyses)
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Archsight
6
+ module Editor
7
+ # ContentHasher generates SHA256 hashes for optimistic locking
8
+ module ContentHasher
9
+ module_function
10
+
11
+ # Generate a hash of YAML content for comparison
12
+ # Normalizes line endings before hashing to ensure consistency across platforms
13
+ # @param content [String] YAML content
14
+ # @return [String] 16-character hex hash
15
+ def hash(content)
16
+ normalized = content.gsub("\r\n", "\n").gsub("\r", "\n")
17
+ Digest::SHA256.hexdigest(normalized)[0, 16]
18
+ end
19
+
20
+ # Validate that content hasn't changed since expected_hash was computed
21
+ # @param path [String] File path
22
+ # @param start_line [Integer] Line number where document starts
23
+ # @param expected_hash [String, nil] Expected content hash
24
+ # @return [Hash, nil] Error hash with :conflict and :error keys, or nil if valid
25
+ def validate(path:, start_line:, expected_hash:)
26
+ return nil unless expected_hash
27
+
28
+ current_content = FileWriter.read_document(path: path, start_line: start_line)
29
+ current_hash = hash(current_content)
30
+
31
+ return nil if current_hash == expected_hash
32
+
33
+ { conflict: true, error: "Conflict: The resource has been modified. Please reload the page and try again." }
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Archsight
4
+ module Editor
5
+ # FileWriter handles reading and writing YAML documents in multi-document files
6
+ module FileWriter
7
+ class WriteError < StandardError; end
8
+
9
+ module_function
10
+
11
+ # Read a YAML document from a file starting at a given line
12
+ # @param path [String] File path
13
+ # @param start_line [Integer] Line number where document starts (1-indexed)
14
+ # @return [String] Document content
15
+ # @raise [WriteError] if file not found or line out of bounds
16
+ def read_document(path:, start_line:)
17
+ raise WriteError, "File not found: #{path}" unless File.exist?(path)
18
+
19
+ lines = File.readlines(path)
20
+ start_idx = start_line - 1 # Convert to 0-indexed
21
+
22
+ raise WriteError, "Line #{start_line} is beyond end of file" if start_idx >= lines.length
23
+
24
+ # Find the end of this document (next --- or EOF)
25
+ end_idx = find_document_end(lines, start_idx)
26
+
27
+ # Extract and join the document lines
28
+ lines[start_idx...end_idx].join
29
+ end
30
+
31
+ # Replace a YAML document in a file starting at a given line
32
+ # @param path [String] File path
33
+ # @param start_line [Integer] Line number where document starts (1-indexed)
34
+ # @param new_yaml [String] New YAML content (without leading ---)
35
+ # @raise [WriteError] if file cannot be written or document not found at expected line
36
+ def replace_document(path:, start_line:, new_yaml:)
37
+ raise WriteError, "File not found: #{path}" unless File.exist?(path)
38
+ raise WriteError, "File not writable: #{path}" unless File.writable?(path)
39
+
40
+ lines = File.readlines(path)
41
+ start_idx = start_line - 1 # Convert to 0-indexed
42
+
43
+ raise WriteError, "Line #{start_line} is beyond end of file" if start_idx >= lines.length
44
+
45
+ # Find the end of this document (next --- or EOF)
46
+ end_idx = find_document_end(lines, start_idx)
47
+
48
+ # Build the new content
49
+ # Ensure new_yaml ends with a newline
50
+ new_yaml = "#{new_yaml}\n" unless new_yaml.end_with?("\n")
51
+
52
+ # Replace the document
53
+ new_lines = lines[0...start_idx] + [new_yaml] + lines[end_idx..]
54
+
55
+ # Write atomically by writing to temp file then renaming
56
+ File.write(path, new_lines.join)
57
+ end
58
+
59
+ # Find the end index of a document (the line index of the next --- or EOF)
60
+ # @param lines [Array<String>] File lines
61
+ # @param start_idx [Integer] Starting line index (0-indexed)
62
+ # @return [Integer] End index (exclusive - the line after the document ends)
63
+ def find_document_end(lines, start_idx)
64
+ # Start searching from the line after start_idx
65
+ idx = start_idx + 1
66
+
67
+ while idx < lines.length
68
+ # Check if this line is a document separator
69
+ return idx if lines[idx].strip == "---"
70
+
71
+ idx += 1
72
+ end
73
+
74
+ # No separator found, document goes to EOF
75
+ lines.length
76
+ end
77
+ end
78
+ end
79
+ end