block-in-file 1.0.0

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 (67) hide show
  1. package/- +3 -0
  2. package/.beads/README.md +85 -0
  3. package/.beads/config.yaml +67 -0
  4. package/.beads/interactions.jsonl +0 -0
  5. package/.beads/issues.jsonl +23 -0
  6. package/.beads/metadata.json +4 -0
  7. package/.git-blame-ignore-revs +2 -0
  8. package/.gitattributes +3 -0
  9. package/.prettierrc.json +5 -0
  10. package/AGENTS.md +40 -0
  11. package/README.md +122 -0
  12. package/block-in-file.ts +150 -0
  13. package/content +10 -0
  14. package/deno.json +14 -0
  15. package/deno.lock +1084 -0
  16. package/doc/PLAN-envsubst.md +200 -0
  17. package/doc/PLAN-restructure.md +114 -0
  18. package/package.json +44 -0
  19. package/src/attributes.ts +161 -0
  20. package/src/backup.ts +180 -0
  21. package/src/block-parser.ts +170 -0
  22. package/src/block-remover.ts +128 -0
  23. package/src/conflict-detection.ts +179 -0
  24. package/src/defaults.ts +23 -0
  25. package/src/envsubst.ts +59 -0
  26. package/src/file-processor.ts +378 -0
  27. package/src/index.ts +5 -0
  28. package/src/input.ts +69 -0
  29. package/src/mode-handler.ts +39 -0
  30. package/src/output.ts +107 -0
  31. package/src/plugins/.beads/.local_version +1 -0
  32. package/src/plugins/.beads/issues.jsonl +21 -0
  33. package/src/plugins/.beads/metadata.json +4 -0
  34. package/src/plugins/config.ts +282 -0
  35. package/src/plugins/diff.ts +109 -0
  36. package/src/plugins/io.ts +72 -0
  37. package/src/plugins/logger.ts +41 -0
  38. package/src/tags/tag-merger.ts +31 -0
  39. package/src/tags/tag-mode.ts +1 -0
  40. package/src/tags/tag.ts +36 -0
  41. package/src/tags/tags.ts +4 -0
  42. package/src/tags/types.ts +4 -0
  43. package/src/timestamp.ts +39 -0
  44. package/src/types.ts +32 -0
  45. package/src/validation.ts +11 -0
  46. package/test/additive-cli.test.ts +109 -0
  47. package/test/additive.test.ts +233 -0
  48. package/test/attributes-integration.test.ts +161 -0
  49. package/test/attributes.test.ts +100 -0
  50. package/test/backup.test.ts +386 -0
  51. package/test/block-in-file.test.ts +235 -0
  52. package/test/block-parser.test.ts +221 -0
  53. package/test/block-remover.test.ts +209 -0
  54. package/test/cli.test.ts +254 -0
  55. package/test/defaults.test.ts +38 -0
  56. package/test/envsubst-edge-cases.test.ts +116 -0
  57. package/test/envsubst-integration.test.ts +78 -0
  58. package/test/envsubst.test.ts +184 -0
  59. package/test/input.test.ts +86 -0
  60. package/test/mode.test.ts +193 -0
  61. package/test/output.test.ts +44 -0
  62. package/test/tag-merger.test.ts +176 -0
  63. package/test/tags.test.ts +116 -0
  64. package/test/timestamp-integration.test.ts +209 -0
  65. package/test/timestamp.test.ts +76 -0
  66. package/tsconfig.json +16 -0
  67. package/vitest.config.ts +8 -0
@@ -0,0 +1,72 @@
1
+ import { plugin } from "gunshi/plugin";
2
+ import * as fs from "node:fs/promises";
3
+ import * as readline from "node:readline";
4
+ import { stdin as input, stdout as output } from "node:process";
5
+ import { performBackup, type BackupOptions } from "../backup.ts";
6
+
7
+ export const pluginId = "blockinfile:io" as const;
8
+ export type PluginId = typeof pluginId;
9
+
10
+ export interface IOExtension {
11
+ readFile: (path: string) => Promise<string>;
12
+ writeFile: (path: string, content: string) => Promise<void>;
13
+ readStdin: () => Promise<string>;
14
+ fileExists: (path: string) => Promise<boolean>;
15
+ backupFile: (path: string, options: BackupOptions, content?: string) => Promise<string | null>;
16
+ rename: (oldPath: string, newPath: string) => Promise<void>;
17
+ deleteFile: (path: string) => Promise<void>;
18
+ }
19
+
20
+ const readStdin = async (): Promise<string> => {
21
+ const rl = readline.createInterface({ input, output });
22
+ const lines: string[] = [];
23
+
24
+ for await (const line of rl) {
25
+ lines.push(line);
26
+ }
27
+
28
+ return lines.join("\n");
29
+ };
30
+
31
+ export default function io() {
32
+ return plugin<{}, typeof pluginId, [], IOExtension>({
33
+ id: pluginId,
34
+ name: "IO Plugin",
35
+ extension: (): IOExtension => ({
36
+ readFile: async (path: string) => {
37
+ if (path === "-") {
38
+ return await readStdin();
39
+ }
40
+ return await fs.readFile(path, "utf-8");
41
+ },
42
+ writeFile: async (path: string, content: string) => {
43
+ if (path === "-") {
44
+ console.log(content);
45
+ return;
46
+ }
47
+ if (path === "--") {
48
+ return;
49
+ }
50
+ await fs.writeFile(path, content, "utf-8");
51
+ },
52
+ readStdin,
53
+ fileExists: async (path: string) => {
54
+ try {
55
+ await fs.access(path);
56
+ return true;
57
+ } catch {
58
+ return false;
59
+ }
60
+ },
61
+ backupFile: async (path: string, options: BackupOptions, content?: string) => {
62
+ return await performBackup(path, options, content);
63
+ },
64
+ rename: async (oldPath: string, newPath: string) => {
65
+ await fs.rename(oldPath, newPath);
66
+ },
67
+ deleteFile: async (path: string) => {
68
+ await fs.unlink(path);
69
+ },
70
+ }),
71
+ });
72
+ }
@@ -0,0 +1,41 @@
1
+ import { plugin } from "gunshi/plugin";
2
+
3
+ export const pluginId = "blockinfile:logger" as const;
4
+ export type PluginId = typeof pluginId;
5
+
6
+ export interface LoggerExtension {
7
+ log: (message: string) => void;
8
+ error: (message: string) => void;
9
+ warn: (message: string) => void;
10
+ debug: (message: string) => void;
11
+ enabled: boolean;
12
+ }
13
+
14
+ export interface LoggerOptions {
15
+ debug?: boolean;
16
+ }
17
+
18
+ export default function logger() {
19
+ return plugin<{}, typeof pluginId, [], LoggerExtension>({
20
+ id: pluginId,
21
+ name: "Logger Plugin",
22
+ setup: (ctx) => {
23
+ ctx.addGlobalOption("debug", {
24
+ type: "boolean",
25
+ short: "d",
26
+ description: "Enable debug output",
27
+ });
28
+ },
29
+ extension: (ctx): LoggerExtension => ({
30
+ log: (message: string) => console.log(message),
31
+ error: (message: string) => console.error(message),
32
+ warn: (message: string) => console.warn(message),
33
+ debug: (message: string) => {
34
+ if (ctx.values.debug) {
35
+ console.debug(`[DEBUG] ${message}`);
36
+ }
37
+ },
38
+ enabled: (ctx.values.debug as boolean | undefined) ?? false,
39
+ }),
40
+ });
41
+ }
@@ -0,0 +1,31 @@
1
+ import type { Tag } from "./types.js";
2
+ import type { TagMode } from "./tag-mode.js";
3
+
4
+ export function mergeTags(existingTags: Tag[], newTags: Tag[]): Tag[] {
5
+ const tagMap = new Map<string, Tag>();
6
+
7
+ for (const tag of existingTags) {
8
+ tagMap.set(tag.name, tag);
9
+ }
10
+
11
+ for (const tag of newTags) {
12
+ tagMap.set(tag.name, tag);
13
+ }
14
+
15
+ return Array.from(tagMap.values());
16
+ }
17
+
18
+ export function replaceTags(existingTags: Tag[], newTags: Tag[]): Tag[] {
19
+ return newTags;
20
+ }
21
+
22
+ export function applyTagMode(existingTags: Tag[], newTags: Tag[], mode: TagMode): Tag[] {
23
+ switch (mode) {
24
+ case "merge":
25
+ return mergeTags(existingTags, newTags);
26
+ case "replace":
27
+ return replaceTags(existingTags, newTags);
28
+ default:
29
+ return mergeTags(existingTags, newTags);
30
+ }
31
+ }
@@ -0,0 +1 @@
1
+ export type TagMode = "merge" | "replace";
@@ -0,0 +1,36 @@
1
+ import type { Tag } from "./types.ts";
2
+
3
+ export function generateTag(name: string, value: string): string {
4
+ return value ? `[${name}:${value}]` : `[${name}]`;
5
+ }
6
+
7
+ export function parseTags(line: string): Tag[] {
8
+ const tags: Tag[] = [];
9
+ const tagRegex = /\[([a-zA-Z0-9_-]+)(?::([^\[\]]+))?\]/g;
10
+ let match;
11
+
12
+ while ((match = tagRegex.exec(line)) !== null) {
13
+ tags.push({
14
+ name: match[1],
15
+ value: match[2] || "",
16
+ });
17
+ }
18
+
19
+ return tags;
20
+ }
21
+
22
+ export function removeTags(line: string): string {
23
+ return line.replace(/\s*\[[a-zA-Z0-9_-]+(?::[^\[\]]+)?\]\s*/g, "").trim();
24
+ }
25
+
26
+ export function addTags(line: string, tags: Tag[]): string {
27
+ if (tags.length === 0) {
28
+ return line;
29
+ }
30
+ const tagStrings = tags.map((t) => generateTag(t.name, t.value));
31
+ return `${line} ${tagStrings.join(" ")}`;
32
+ }
33
+
34
+ export function stripTagsForMatching(line: string): string {
35
+ return removeTags(line);
36
+ }
@@ -0,0 +1,4 @@
1
+ export type { Tag } from "./types.ts";
2
+ export { generateTag, parseTags, removeTags, addTags, stripTagsForMatching } from "./tag.ts";
3
+ export type { TagMode } from "./tag-mode.ts";
4
+ export { mergeTags, replaceTags, applyTagMode } from "./tag-merger.ts";
@@ -0,0 +1,4 @@
1
+ export interface Tag {
2
+ name: string;
3
+ value: string;
4
+ }
@@ -0,0 +1,39 @@
1
+ import { generateTag as generateTagFromUtil } from "./tags/tags.ts";
2
+
3
+ export type TimestampFormat = "epoch-nano" | "epoch-sec" | "iso8601";
4
+
5
+ function generateTimestamp(format: TimestampFormat): string {
6
+ const now = new Date();
7
+
8
+ switch (format) {
9
+ case "epoch-nano": {
10
+ return `${now.getTime()}000000`;
11
+ }
12
+ case "epoch-sec": {
13
+ return Math.floor(now.getTime() / 1000).toString();
14
+ }
15
+ case "iso8601": {
16
+ return now.toISOString();
17
+ }
18
+ default: {
19
+ return `${now.getTime()}000000`;
20
+ }
21
+ }
22
+ }
23
+
24
+ export function generateTimestampTag(format: TimestampFormat): string {
25
+ const timestamp = generateTimestamp(format);
26
+ return generateTagFromUtil("timestamp", timestamp);
27
+ }
28
+
29
+ export function parseTimestampFormat(value: string | undefined): TimestampFormat | undefined {
30
+ if (!value) return undefined;
31
+
32
+ if (value === "epoch-nano" || value === "epoch-sec" || value === "iso8601") {
33
+ return value;
34
+ }
35
+
36
+ throw new Error(
37
+ `Invalid timestamp format: ${value}. Valid options: epoch-nano, epoch-sec, iso8601`,
38
+ );
39
+ }
package/src/types.ts ADDED
@@ -0,0 +1,32 @@
1
+ export type CreateArg = boolean | 0 | 1 | "file" | "block";
2
+
3
+ export interface InputOptions {
4
+ create?: boolean;
5
+ }
6
+
7
+ export interface BlockInFileOptions {
8
+ after?: string | RegExp | boolean;
9
+ before?: string | RegExp | boolean;
10
+ comment: string;
11
+ create?: CreateArg;
12
+ debug?: boolean;
13
+ diff?: string | boolean;
14
+ dos?: boolean;
15
+ envsubst?: "recursive" | "non-recursive" | false;
16
+ input?: string;
17
+ markerStart: string;
18
+ markerEnd: string;
19
+ name: string;
20
+ output?: string;
21
+ additive?: boolean;
22
+ additiveBefore?: string;
23
+ additiveAfter?: string;
24
+ timestamp?: string;
25
+ tagMode?: string;
26
+ }
27
+
28
+ export interface ParseResult {
29
+ outputs: string[];
30
+ matched: number;
31
+ opened?: number;
32
+ }
@@ -0,0 +1,11 @@
1
+ import { x } from "tinyexec";
2
+ import { tokenizeArgs } from "args-tokenizer";
3
+
4
+ export async function runValidation(file: string, validateCmd: string): Promise<void> {
5
+ const substitutedCmd = validateCmd.replaceAll("%s", file);
6
+ const [command, ...args] = tokenizeArgs(substitutedCmd);
7
+ const result = await x(command, args, { throwOnError: true });
8
+ if (result.exitCode !== 0) {
9
+ throw new Error(result.stderr || "Validation command failed");
10
+ }
11
+ }
@@ -0,0 +1,109 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import * as fs from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import * as os from "node:os";
5
+ import { execSync } from "node:child_process";
6
+
7
+ describe("CLI additive mode", () => {
8
+ let tempDir: string;
9
+
10
+ beforeEach(async () => {
11
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "blockinfile-cli-additive-"));
12
+ });
13
+
14
+ afterEach(async () => {
15
+ await fs.rm(tempDir, { recursive: true, force: true });
16
+ });
17
+
18
+ function runCli(args: string, input?: string): string {
19
+ const cwd = path.resolve(import.meta.dirname!, "..");
20
+ const cmd = `npx tsx block-in-file.ts ${args}`;
21
+ return execSync(cmd, {
22
+ cwd,
23
+ input,
24
+ encoding: "utf-8",
25
+ });
26
+ }
27
+
28
+ it("adds missing lines with --additive", async () => {
29
+ const targetFile = path.join(tempDir, "target.txt");
30
+ await fs.writeFile(targetFile, "header\n# blockinfile start\nline1\n# blockinfile end\nfooter");
31
+
32
+ runCli(`--additive -i - ${targetFile}`, "line1\nline2\nline3");
33
+
34
+ const result = await fs.readFile(targetFile, "utf-8");
35
+ expect(result).toContain("line1");
36
+ expect(result).toContain("line2");
37
+ expect(result).toContain("line3");
38
+ const lines = result.split("\n");
39
+ const blockStartIndex = lines.indexOf("# blockinfile start");
40
+ const blockEndIndex = lines.indexOf("# blockinfile end");
41
+ const blockContent = lines.slice(blockStartIndex + 1, blockEndIndex);
42
+ expect(blockContent).toEqual(["line1", "line2", "line3"]);
43
+ });
44
+
45
+ it("adds missing lines before existing content with --additive-before BOF", async () => {
46
+ const targetFile = path.join(tempDir, "target.txt");
47
+ await fs.writeFile(targetFile, "header\n# blockinfile start\nline2\n# blockinfile end\nfooter");
48
+
49
+ runCli(`--additive --additive-before BOF -i - ${targetFile}`, "line1\nline2\nline3");
50
+
51
+ const result = await fs.readFile(targetFile, "utf-8");
52
+ const lines = result.split("\n");
53
+ const blockStartIndex = lines.indexOf("# blockinfile start");
54
+ const blockEndIndex = lines.indexOf("# blockinfile end");
55
+ const blockContent = lines.slice(blockStartIndex + 1, blockEndIndex);
56
+ expect(blockContent).toEqual(["line1", "line3", "line2"]);
57
+ });
58
+
59
+ it("adds missing lines after matching line with --additive-after regex", async () => {
60
+ const targetFile = path.join(tempDir, "target.txt");
61
+ await fs.writeFile(
62
+ targetFile,
63
+ "header\n# blockinfile start\nline1\nmarker\nline3\n# blockinfile end\nfooter",
64
+ );
65
+
66
+ runCli(`--additive --additive-after '^marker$' -i - ${targetFile}`, "line1\nline2\nline3");
67
+
68
+ const result = await fs.readFile(targetFile, "utf-8");
69
+ const lines = result.split("\n");
70
+ const blockStartIndex = lines.indexOf("# blockinfile start");
71
+ const blockEndIndex = lines.indexOf("# blockinfile end");
72
+ const blockContent = lines.slice(blockStartIndex + 1, blockEndIndex);
73
+ expect(blockContent).toEqual(["line1", "marker", "line2", "line3"]);
74
+ });
75
+
76
+ it("errors when both --additive-before and --additive-after are specified", async () => {
77
+ const targetFile = path.join(tempDir, "target.txt");
78
+ await fs.writeFile(targetFile, "header\nfooter");
79
+
80
+ expect(() =>
81
+ runCli(`--additive --additive-before BOF --additive-after EOF -i - ${targetFile}`, "line1"),
82
+ ).toThrow("Cannot specify both --additive-before and --additive-after");
83
+ });
84
+
85
+ it("outputs to stdout with --additive", async () => {
86
+ const targetFile = path.join(tempDir, "target.txt");
87
+ await fs.writeFile(targetFile, "header\n# blockinfile start\nline1\n# blockinfile end\nfooter");
88
+
89
+ const output = runCli(`--additive -i - ${targetFile} -o -`, "line1\nline2\nline3");
90
+
91
+ expect(output).toContain("line1");
92
+ expect(output).toContain("line2");
93
+ expect(output).toContain("line3");
94
+ });
95
+
96
+ it("works with empty block", async () => {
97
+ const targetFile = path.join(tempDir, "target.txt");
98
+ await fs.writeFile(targetFile, "header\n# blockinfile start\n# blockinfile end\nfooter");
99
+
100
+ runCli(`--additive -i - ${targetFile}`, "line1\nline2\nline3");
101
+
102
+ const result = await fs.readFile(targetFile, "utf-8");
103
+ const lines = result.split("\n");
104
+ const blockStartIndex = lines.indexOf("# blockinfile start");
105
+ const blockEndIndex = lines.indexOf("# blockinfile end");
106
+ const blockContent = lines.slice(blockStartIndex + 1, blockEndIndex);
107
+ expect(blockContent).toEqual(["line1", "line2", "line3"]);
108
+ });
109
+ });
@@ -0,0 +1,233 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseAndInsertBlock } from "../src/block-parser.ts";
3
+
4
+ describe("additive mode", () => {
5
+ const defaultOpts = {
6
+ opener: "# blockinfile start",
7
+ closer: "# blockinfile end",
8
+ inputBlock: "line1\nline2\nline3",
9
+ };
10
+
11
+ it("adds missing lines to existing block", () => {
12
+ const fileContent = "header\n# blockinfile start\nline1\n# blockinfile end\nfooter";
13
+ const result = parseAndInsertBlock(fileContent, {
14
+ ...defaultOpts,
15
+ additive: true,
16
+ });
17
+
18
+ expect(result.outputs).toEqual([
19
+ "header",
20
+ "# blockinfile start",
21
+ "line1",
22
+ "line2",
23
+ "line3",
24
+ "# blockinfile end",
25
+ "footer",
26
+ ]);
27
+ });
28
+
29
+ it("preserves existing content and adds missing lines", () => {
30
+ const fileContent = "header\n# blockinfile start\nline2\n# blockinfile end\nfooter";
31
+ const result = parseAndInsertBlock(fileContent, {
32
+ ...defaultOpts,
33
+ additive: true,
34
+ });
35
+
36
+ expect(result.outputs).toEqual([
37
+ "header",
38
+ "# blockinfile start",
39
+ "line2",
40
+ "line1",
41
+ "line3",
42
+ "# blockinfile end",
43
+ "footer",
44
+ ]);
45
+ });
46
+
47
+ it("does nothing when all lines already exist", () => {
48
+ const fileContent =
49
+ "header\n# blockinfile start\nline1\nline2\nline3\n# blockinfile end\nfooter";
50
+ const result = parseAndInsertBlock(fileContent, {
51
+ ...defaultOpts,
52
+ additive: true,
53
+ });
54
+
55
+ expect(result.outputs).toEqual([
56
+ "header",
57
+ "# blockinfile start",
58
+ "line1",
59
+ "line2",
60
+ "line3",
61
+ "# blockinfile end",
62
+ "footer",
63
+ ]);
64
+ });
65
+
66
+ it("adds missing lines before existing content with --additive-before BOF", () => {
67
+ const fileContent = "header\n# blockinfile start\nline2\n# blockinfile end\nfooter";
68
+ const result = parseAndInsertBlock(fileContent, {
69
+ ...defaultOpts,
70
+ additive: true,
71
+ additiveBefore: "BOF",
72
+ });
73
+
74
+ expect(result.outputs).toEqual([
75
+ "header",
76
+ "# blockinfile start",
77
+ "line1",
78
+ "line3",
79
+ "line2",
80
+ "# blockinfile end",
81
+ "footer",
82
+ ]);
83
+ });
84
+
85
+ it("adds missing lines after existing content with --additive-after EOB (default)", () => {
86
+ const fileContent = "header\n# blockinfile start\nline1\n# blockinfile end\nfooter";
87
+ const result = parseAndInsertBlock(fileContent, {
88
+ ...defaultOpts,
89
+ additive: true,
90
+ additiveAfter: "EOB",
91
+ });
92
+
93
+ expect(result.outputs).toEqual([
94
+ "header",
95
+ "# blockinfile start",
96
+ "line1",
97
+ "line2",
98
+ "line3",
99
+ "# blockinfile end",
100
+ "footer",
101
+ ]);
102
+ });
103
+
104
+ it("adds missing lines after existing content with --additive-after EOF", () => {
105
+ const fileContent = "header\n# blockinfile start\nline1\n# blockinfile end\nfooter";
106
+ const result = parseAndInsertBlock(fileContent, {
107
+ ...defaultOpts,
108
+ additive: true,
109
+ additiveAfter: "EOF",
110
+ });
111
+
112
+ expect(result.outputs).toEqual([
113
+ "header",
114
+ "# blockinfile start",
115
+ "line1",
116
+ "line2",
117
+ "line3",
118
+ "# blockinfile end",
119
+ "footer",
120
+ ]);
121
+ });
122
+
123
+ it("adds missing lines after matching line with --additive-after regex", () => {
124
+ const fileContent =
125
+ "header\n# blockinfile start\nline1\nmarker\nline3\n# blockinfile end\nfooter";
126
+ const result = parseAndInsertBlock(fileContent, {
127
+ ...defaultOpts,
128
+ additive: true,
129
+ additiveAfter: /^marker$/,
130
+ });
131
+
132
+ expect(result.outputs).toEqual([
133
+ "header",
134
+ "# blockinfile start",
135
+ "line1",
136
+ "marker",
137
+ "line2",
138
+ "line3",
139
+ "# blockinfile end",
140
+ "footer",
141
+ ]);
142
+ });
143
+
144
+ it("adds missing lines before matching line with --additive-before regex", () => {
145
+ const fileContent =
146
+ "header\n# blockinfile start\nline1\nmarker\nline3\n# blockinfile end\nfooter";
147
+ const result = parseAndInsertBlock(fileContent, {
148
+ ...defaultOpts,
149
+ additive: true,
150
+ additiveBefore: /^marker$/,
151
+ });
152
+
153
+ expect(result.outputs).toEqual([
154
+ "header",
155
+ "# blockinfile start",
156
+ "line1",
157
+ "line2",
158
+ "marker",
159
+ "line3",
160
+ "# blockinfile end",
161
+ "footer",
162
+ ]);
163
+ });
164
+
165
+ it("creates new block if block does not exist and additive is set", () => {
166
+ const fileContent = "header\nfooter";
167
+ const result = parseAndInsertBlock(fileContent, {
168
+ ...defaultOpts,
169
+ additive: true,
170
+ });
171
+
172
+ expect(result.outputs).toContain("# blockinfile start");
173
+ expect(result.outputs).toContain("line1");
174
+ expect(result.outputs).toContain("line2");
175
+ expect(result.outputs).toContain("line3");
176
+ expect(result.outputs).toContain("# blockinfile end");
177
+ });
178
+
179
+ it("works with empty block", () => {
180
+ const fileContent = "header\n# blockinfile start\n# blockinfile end\nfooter";
181
+ const result = parseAndInsertBlock(fileContent, {
182
+ ...defaultOpts,
183
+ additive: true,
184
+ });
185
+
186
+ expect(result.outputs).toEqual([
187
+ "header",
188
+ "# blockinfile start",
189
+ "line1",
190
+ "line2",
191
+ "line3",
192
+ "# blockinfile end",
193
+ "footer",
194
+ ]);
195
+ });
196
+
197
+ it("replaces block when additive is false (default behavior)", () => {
198
+ const fileContent =
199
+ "header\n# blockinfile start\nold line1\nold line2\n# blockinfile end\nfooter";
200
+ const result = parseAndInsertBlock(fileContent, {
201
+ ...defaultOpts,
202
+ additive: false,
203
+ });
204
+
205
+ expect(result.outputs).toEqual([
206
+ "header",
207
+ "# blockinfile start",
208
+ "line1",
209
+ "line2",
210
+ "line3",
211
+ "# blockinfile end",
212
+ "footer",
213
+ ]);
214
+ });
215
+
216
+ it("handles duplicate lines in input without duplication", () => {
217
+ const fileContent = "header\n# blockinfile start\nline1\n# blockinfile end\nfooter";
218
+ const result = parseAndInsertBlock(fileContent, {
219
+ ...defaultOpts,
220
+ inputBlock: "line1\nline1\nline2",
221
+ additive: true,
222
+ });
223
+
224
+ expect(result.outputs).toEqual([
225
+ "header",
226
+ "# blockinfile start",
227
+ "line1",
228
+ "line2",
229
+ "# blockinfile end",
230
+ "footer",
231
+ ]);
232
+ });
233
+ });