@cvr/repo 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.
@@ -0,0 +1,201 @@
1
+ import { Effect, Layer, Option, Ref } from "effect"
2
+ import { RegistryService } from "../../services/registry.js"
3
+ import type { PackageSpec, Registry } from "../../types.js"
4
+ import { SpecParseError } from "../../types.js"
5
+ import { recordCall, type SequenceRef } from "../sequence.js"
6
+
7
+ // ─── Mock State ───────────────────────────────────────────────────────────────
8
+
9
+ export interface MockRegistryState {
10
+ fetchedSpecs: Map<string, PackageSpec>
11
+ }
12
+
13
+ export const defaultMockRegistryState: MockRegistryState = {
14
+ fetchedSpecs: new Map(),
15
+ }
16
+
17
+ // ─── Mock Implementation ──────────────────────────────────────────────────────
18
+
19
+ export interface CreateMockRegistryServiceOptions {
20
+ initialState?: Partial<MockRegistryState>
21
+ sequenceRef?: SequenceRef
22
+ }
23
+
24
+ export function createMockRegistryService(options: CreateMockRegistryServiceOptions = {}): {
25
+ layer: Layer.Layer<RegistryService>
26
+ stateRef: Ref.Ref<MockRegistryState>
27
+ getState: () => Effect.Effect<MockRegistryState>
28
+ } {
29
+ const initialState = options.initialState ?? {}
30
+ const sequenceRef = options.sequenceRef
31
+
32
+ const state: MockRegistryState = {
33
+ ...defaultMockRegistryState,
34
+ ...initialState,
35
+ fetchedSpecs: new Map(initialState.fetchedSpecs ?? []),
36
+ }
37
+ const stateRef = Ref.unsafeMake(state)
38
+
39
+ const record = (method: string, args: unknown, result?: unknown): Effect.Effect<void> =>
40
+ sequenceRef ? recordCall(sequenceRef, { service: "registry", method, args, result }) : Effect.void
41
+
42
+ const layer = Layer.succeed(
43
+ RegistryService,
44
+ RegistryService.of({
45
+ parseSpec: (input) =>
46
+ Effect.gen(function* () {
47
+ const result = parseSpecSync(input)
48
+ yield* record("parseSpec", { input }, result)
49
+ return result
50
+ }),
51
+
52
+ fetch: (spec, destPath, options) =>
53
+ Effect.gen(function* () {
54
+ yield* record("fetch", { spec, destPath, options })
55
+ yield* Ref.update(stateRef, (s) => {
56
+ const newFetched = new Map(s.fetchedSpecs)
57
+ newFetched.set(destPath, spec)
58
+ return { ...s, fetchedSpecs: newFetched }
59
+ })
60
+ }),
61
+ })
62
+ )
63
+
64
+ return {
65
+ layer,
66
+ stateRef,
67
+ getState: () => Ref.get(stateRef),
68
+ }
69
+ }
70
+
71
+ // ─── Spec Parsing (copied from registry.ts for test layer) ────────────────────
72
+
73
+ type ParseResult = PackageSpec | { error: string }
74
+
75
+ function parseGithubSpec(input: string): ParseResult {
76
+ const refMatch = input.match(/^([^@#]+)[@#](.+)$/)
77
+ if (refMatch) {
78
+ const [, name, ref] = refMatch
79
+ if (!name?.includes("/")) {
80
+ return { error: "GitHub spec must be owner/repo format" }
81
+ }
82
+ return {
83
+ registry: "github" as Registry,
84
+ name: name,
85
+ version: Option.some(ref!),
86
+ }
87
+ }
88
+
89
+ if (!input.includes("/")) {
90
+ return { error: "GitHub spec must be owner/repo format" }
91
+ }
92
+
93
+ return {
94
+ registry: "github" as Registry,
95
+ name: input,
96
+ version: Option.none(),
97
+ }
98
+ }
99
+
100
+ function parseNpmSpec(input: string): ParseResult {
101
+ if (input.startsWith("@")) {
102
+ const match = input.match(/^(@[^@]+)(?:@(.+))?$/)
103
+ if (!match) {
104
+ return { error: "Invalid scoped npm package spec" }
105
+ }
106
+ const [, name, version] = match
107
+ return {
108
+ registry: "npm" as Registry,
109
+ name: name!,
110
+ version: version ? Option.some(version) : Option.none(),
111
+ }
112
+ }
113
+
114
+ const parts = input.split("@")
115
+ if (parts.length > 2) {
116
+ return { error: "Invalid npm package spec" }
117
+ }
118
+
119
+ const [name, version] = parts
120
+ if (!name) {
121
+ return { error: "Package name is required" }
122
+ }
123
+
124
+ return {
125
+ registry: "npm" as Registry,
126
+ name,
127
+ version: version ? Option.some(version) : Option.none(),
128
+ }
129
+ }
130
+
131
+ function parsePypiSpec(input: string): ParseResult {
132
+ const match = input.match(/^([^@=]+)(?:[@=]=?(.+))?$/)
133
+ if (!match) {
134
+ return { error: "Invalid PyPI package spec" }
135
+ }
136
+
137
+ const [, name, version] = match
138
+ if (!name) {
139
+ return { error: "Package name is required" }
140
+ }
141
+
142
+ return {
143
+ registry: "pypi" as Registry,
144
+ name: name.trim(),
145
+ version: version ? Option.some(version.trim()) : Option.none(),
146
+ }
147
+ }
148
+
149
+ function parseCratesSpec(input: string): ParseResult {
150
+ const parts = input.split("@")
151
+ if (parts.length > 2) {
152
+ return { error: "Invalid crates.io spec" }
153
+ }
154
+
155
+ const [name, version] = parts
156
+ if (!name) {
157
+ return { error: "Crate name is required" }
158
+ }
159
+
160
+ return {
161
+ registry: "crates" as Registry,
162
+ name: name.trim(),
163
+ version: version ? Option.some(version.trim()) : Option.none(),
164
+ }
165
+ }
166
+
167
+ function parseSpecSync(input: string): PackageSpec {
168
+ const trimmed = input.trim()
169
+
170
+ let result: ParseResult
171
+
172
+ if (trimmed.startsWith("npm:")) {
173
+ result = parseNpmSpec(trimmed.slice(4))
174
+ } else if (trimmed.startsWith("pypi:") || trimmed.startsWith("pip:")) {
175
+ const prefix = trimmed.startsWith("pypi:") ? "pypi:" : "pip:"
176
+ result = parsePypiSpec(trimmed.slice(prefix.length))
177
+ } else if (
178
+ trimmed.startsWith("crates:") ||
179
+ trimmed.startsWith("cargo:") ||
180
+ trimmed.startsWith("rust:")
181
+ ) {
182
+ const prefixLen = trimmed.indexOf(":") + 1
183
+ result = parseCratesSpec(trimmed.slice(prefixLen))
184
+ } else if (trimmed.startsWith("github:")) {
185
+ result = parseGithubSpec(trimmed.slice(7))
186
+ } else if (trimmed.includes("/") && !trimmed.startsWith("@")) {
187
+ result = parseGithubSpec(trimmed)
188
+ } else {
189
+ result = parseNpmSpec(trimmed)
190
+ }
191
+
192
+ if ("error" in result) {
193
+ throw new SpecParseError({ input, message: result.error })
194
+ }
195
+
196
+ return result
197
+ }
198
+
199
+ // ─── Preset Configurations ────────────────────────────────────────────────────
200
+
201
+ export const MockRegistryServiceDefault = createMockRegistryService()
@@ -0,0 +1,157 @@
1
+ import { Command } from "@effect/cli"
2
+ import { BunContext } from "@effect/platform-bun"
3
+ import { Effect, Either, Layer, Ref } from "effect"
4
+ import { expect } from "vitest"
5
+
6
+ import { fetch } from "../commands/fetch.js"
7
+ import { list } from "../commands/list.js"
8
+ import { search } from "../commands/search.js"
9
+ import { remove } from "../commands/remove.js"
10
+ import { clean } from "../commands/clean.js"
11
+ import { prune } from "../commands/prune.js"
12
+ import { stats } from "../commands/stats.js"
13
+ import { open } from "../commands/open.js"
14
+ import { path } from "../commands/path.js"
15
+ import { info } from "../commands/info.js"
16
+
17
+ import { createTestLayer, type CreateTestLayerOptions } from "./index.js"
18
+ import type { ExpectedCall, RecordedCall } from "./sequence.js"
19
+
20
+ // ─── Root Command ──────────────────────────────────────────────────────────────
21
+
22
+ const rootCommand = Command.make("repo").pipe(
23
+ Command.withDescription("Multi-registry source code cache manager"),
24
+ Command.withSubcommands([fetch, list, search, remove, clean, prune, stats, open, path, info])
25
+ )
26
+
27
+ // ─── Re-export types ───────────────────────────────────────────────────────────
28
+
29
+ export type { ExpectedCall } from "./sequence.js"
30
+
31
+ // ─── Sequence Matching ────────────────────────────────────────────────────────
32
+
33
+ function assertSequenceContains(actual: RecordedCall[], expected: ExpectedCall[]): void {
34
+ let actualIndex = 0
35
+
36
+ for (const exp of expected) {
37
+ let found = false
38
+
39
+ while (actualIndex < actual.length) {
40
+ const act = actual[actualIndex]!
41
+ actualIndex++
42
+
43
+ if (act.service === exp.service && act.method === exp.method) {
44
+ if (exp.match) {
45
+ expect(act.args).toMatchObject(exp.match)
46
+ }
47
+ found = true
48
+ break
49
+ }
50
+ }
51
+
52
+ if (!found) {
53
+ const remainingCalls = actual.slice(Math.max(0, actualIndex - 1))
54
+ const formattedCalls = remainingCalls.map((c) => ` ${c.service}.${c.method}`).join("\n")
55
+ throw new Error(
56
+ `Expected ${exp.service}.${exp.method} not found in sequence.\n` +
57
+ `Remaining calls:\n${formattedCalls || " (none)"}\n` +
58
+ `Full sequence:\n${actual.map((c) => ` ${c.service}.${c.method}`).join("\n")}`
59
+ )
60
+ }
61
+ }
62
+ }
63
+
64
+ // ─── CLI Test Runner ──────────────────────────────────────────────────────────
65
+
66
+ export class CliTestRunner {
67
+ constructor(
68
+ private args: string[],
69
+ private options: CreateTestLayerOptions
70
+ ) {}
71
+
72
+ expectSequence(expected: ExpectedCall[]): Effect.Effect<void> {
73
+ return Effect.gen(this, function* () {
74
+ const { layer, sequenceRef } = createTestLayer(this.options)
75
+
76
+ const cli = Command.run(rootCommand, { name: "repo", version: "0.0.0-test" })
77
+ const argv = ["bun", "repo", ...this.args]
78
+ const fullLayer = layer.pipe(Layer.provideMerge(BunContext.layer))
79
+ yield* cli(argv).pipe(Effect.provide(fullLayer), Effect.either)
80
+
81
+ const actual = yield* Ref.get(sequenceRef)
82
+ assertSequenceContains(actual, expected)
83
+ })
84
+ }
85
+
86
+ expectError(errorTag: string): Effect.Effect<void> {
87
+ return Effect.gen(this, function* () {
88
+ const { layer } = createTestLayer(this.options)
89
+
90
+ const cli = Command.run(rootCommand, { name: "repo", version: "0.0.0-test" })
91
+ const fullLayer = layer.pipe(Layer.provideMerge(BunContext.layer))
92
+ const result = yield* cli(["bun", "repo", ...this.args]).pipe(
93
+ Effect.provide(fullLayer),
94
+ Effect.either
95
+ )
96
+
97
+ expect(Either.isLeft(result)).toBe(true)
98
+ if (Either.isLeft(result)) {
99
+ const error = result.left as { _tag?: string }
100
+ expect(error._tag).toBe(errorTag)
101
+ }
102
+ })
103
+ }
104
+
105
+ expectSuccess(): Effect.Effect<void> {
106
+ return this.expectSequence([])
107
+ }
108
+
109
+ getSequence(): Effect.Effect<RecordedCall[]> {
110
+ return Effect.gen(this, function* () {
111
+ const { layer, sequenceRef } = createTestLayer(this.options)
112
+
113
+ const cli = Command.run(rootCommand, { name: "repo", version: "0.0.0-test" })
114
+ const fullLayer = layer.pipe(Layer.provideMerge(BunContext.layer))
115
+ yield* cli(["bun", "repo", ...this.args]).pipe(
116
+ Effect.provide(fullLayer),
117
+ Effect.either
118
+ )
119
+
120
+ return yield* Ref.get(sequenceRef)
121
+ })
122
+ }
123
+ }
124
+
125
+ // ─── Main API ─────────────────────────────────────────────────────────────────
126
+
127
+ /**
128
+ * Creates a CLI test runner with the given arguments and options.
129
+ *
130
+ * IMPORTANT: Due to @effect/cli parser behavior, options with space-separated values
131
+ * must come BEFORE positional arguments. Alternatively, use equals syntax (--option=value).
132
+ *
133
+ * @example
134
+ * ```ts
135
+ * // CORRECT: options before positional arg
136
+ * runCli('fetch -f vercel/next.js', {...})
137
+ *
138
+ * // CORRECT: equals syntax
139
+ * runCli('fetch vercel/next.js --force', {...})
140
+ *
141
+ * // Full example:
142
+ * it.effect('fetches a GitHub repo', () =>
143
+ * runCli('fetch vercel/next.js', {
144
+ * cache: { cacheDir: '/tmp/test' },
145
+ * }).expectSequence([
146
+ * { service: 'registry', method: 'parseSpec' },
147
+ * { service: 'cache', method: 'getPath' },
148
+ * { service: 'cache', method: 'exists' },
149
+ * { service: 'git', method: 'clone' },
150
+ * { service: 'metadata', method: 'add' },
151
+ * ])
152
+ * );
153
+ * ```
154
+ */
155
+ export function runCli(args: string, options: CreateTestLayerOptions = {}): CliTestRunner {
156
+ return new CliTestRunner(args.split(/\s+/), options)
157
+ }
@@ -0,0 +1,57 @@
1
+ import { Effect, Ref } from "effect"
2
+
3
+ // ─── Types ────────────────────────────────────────────────────────────────────
4
+
5
+ /**
6
+ * A recorded call to a mock service method.
7
+ * Used for sequence-based test assertions.
8
+ */
9
+ export interface RecordedCall {
10
+ /** Service name (e.g., 'git', 'cache', 'registry') */
11
+ service: string
12
+ /** Method name (e.g., 'clone', 'getPath', 'parseSpec') */
13
+ method: string
14
+ /** Arguments passed to the method */
15
+ args: unknown
16
+ /** Return value (optional, for debugging) */
17
+ result?: unknown
18
+ }
19
+
20
+ /**
21
+ * Expected call for sequence matching.
22
+ * Uses partial matching - only specified fields are checked.
23
+ */
24
+ export interface ExpectedCall {
25
+ service: string
26
+ method: string
27
+ /** Partial match on args - uses expect().toMatchObject() */
28
+ match?: Record<string, unknown>
29
+ }
30
+
31
+ // ─── Sequence Ref ─────────────────────────────────────────────────────────────
32
+
33
+ export type SequenceRef = Ref.Ref<RecordedCall[]>
34
+
35
+ /**
36
+ * Create a new sequence ref for recording calls.
37
+ */
38
+ export function createSequenceRef(): SequenceRef {
39
+ return Ref.unsafeMake<RecordedCall[]>([])
40
+ }
41
+
42
+ /**
43
+ * Record a call to the sequence.
44
+ */
45
+ export function recordCall(
46
+ sequenceRef: SequenceRef,
47
+ call: Omit<RecordedCall, "result"> & { result?: unknown }
48
+ ): Effect.Effect<void> {
49
+ return Ref.update(sequenceRef, (seq) => [...seq, call])
50
+ }
51
+
52
+ /**
53
+ * Get all recorded calls.
54
+ */
55
+ export function getSequence(sequenceRef: SequenceRef): Effect.Effect<RecordedCall[]> {
56
+ return Ref.get(sequenceRef)
57
+ }
package/src/types.ts ADDED
@@ -0,0 +1,101 @@
1
+ import { Data, Schema } from "effect"
2
+
3
+ // Registry types
4
+ export type Registry = "github" | "npm" | "pypi" | "crates"
5
+
6
+ // Package spec - identifies a package/repo across registries
7
+ export const PackageSpec = Schema.Struct({
8
+ registry: Schema.Literal("github", "npm", "pypi", "crates"),
9
+ name: Schema.String,
10
+ version: Schema.optionalWith(Schema.String, { as: "Option" }),
11
+ })
12
+ export type PackageSpec = typeof PackageSpec.Type
13
+
14
+ // Metadata stored per-cached entry
15
+ export const RepoMetadata = Schema.Struct({
16
+ spec: PackageSpec,
17
+ fetchedAt: Schema.String,
18
+ lastAccessedAt: Schema.String,
19
+ sizeBytes: Schema.Number,
20
+ path: Schema.String,
21
+ })
22
+ export type RepoMetadata = typeof RepoMetadata.Type
23
+
24
+ // Global metadata index
25
+ export const MetadataIndex = Schema.Struct({
26
+ version: Schema.Number,
27
+ repos: Schema.Array(RepoMetadata),
28
+ })
29
+ export type MetadataIndex = typeof MetadataIndex.Type
30
+
31
+ // Tagged Errors
32
+ export class SpecParseError extends Data.TaggedError("SpecParseError")<{
33
+ readonly input: string
34
+ readonly message: string
35
+ }> {}
36
+
37
+ export class RegistryError extends Data.TaggedError("RegistryError")<{
38
+ readonly registry: Registry
39
+ readonly operation: string
40
+ readonly cause: unknown
41
+ }> {}
42
+
43
+ export class GitError extends Data.TaggedError("GitError")<{
44
+ readonly operation: string
45
+ readonly repo: string
46
+ readonly cause: unknown
47
+ }> {}
48
+
49
+ export class CacheError extends Data.TaggedError("CacheError")<{
50
+ readonly operation: string
51
+ readonly path: string
52
+ readonly cause: unknown
53
+ }> {}
54
+
55
+ export class MetadataError extends Data.TaggedError("MetadataError")<{
56
+ readonly operation: string
57
+ readonly cause: unknown
58
+ }> {}
59
+
60
+ export class NotFoundError extends Data.TaggedError("NotFoundError")<{
61
+ readonly spec: PackageSpec
62
+ }> {}
63
+
64
+ export class NetworkError extends Data.TaggedError("NetworkError")<{
65
+ readonly url: string
66
+ readonly cause: unknown
67
+ }> {}
68
+
69
+ export class OpenError extends Data.TaggedError("OpenError")<{
70
+ readonly command: string
71
+ readonly cause: unknown
72
+ }> {}
73
+
74
+ // Utility to format bytes
75
+ export const formatBytes = (bytes: number): string => {
76
+ if (bytes < 1024) return `${bytes} B`
77
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
78
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
79
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
80
+ }
81
+
82
+ // Utility to format relative time
83
+ export const formatRelativeTime = (date: Date): string => {
84
+ const now = new Date()
85
+ const diffMs = now.getTime() - date.getTime()
86
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
87
+
88
+ if (diffDays === 0) return "today"
89
+ if (diffDays === 1) return "yesterday"
90
+ if (diffDays < 7) return `${diffDays} days ago`
91
+ if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`
92
+ if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`
93
+ return `${Math.floor(diffDays / 365)} years ago`
94
+ }
95
+
96
+ // Spec display helper
97
+ export const specToString = (spec: PackageSpec): string => {
98
+ const prefix = spec.registry === "github" ? "" : `${spec.registry}:`
99
+ const version = spec.version._tag === "Some" ? `@${spec.version.value}` : ""
100
+ return `${prefix}${spec.name}${version}`
101
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "preserve",
5
+ "moduleResolution": "bundler",
6
+ "moduleDetection": "force",
7
+ "strict": true,
8
+ "exactOptionalPropertyTypes": true,
9
+ "noUnusedLocals": true,
10
+ "noImplicitOverride": true,
11
+ "noUncheckedIndexedAccess": true,
12
+ "verbatimModuleSyntax": true,
13
+ "esModuleInterop": true,
14
+ "skipLibCheck": true,
15
+ "forceConsistentCasingInFileNames": true,
16
+ "outDir": "dist",
17
+ "rootDir": "src",
18
+ "declaration": true,
19
+ "declarationMap": true,
20
+ "sourceMap": true,
21
+ "noEmit": true,
22
+ "types": ["bun-types"],
23
+ "plugins": [{ "name": "@effect/language-service" }]
24
+ },
25
+ "include": ["src/**/*"],
26
+ "exclude": ["node_modules", "dist"]
27
+ }