Skip to content

Commit a4b0d41

Browse files
committed
Improve testing in the backend
- Introduced Config class for test isolation
1 parent bb93969 commit a4b0d41

File tree

11 files changed

+725
-197
lines changed

11 files changed

+725
-197
lines changed

src/config.ts

Lines changed: 163 additions & 143 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,6 @@ import * as os from "os";
55
import * as jsonc from "jsonc-parser";
66
import type { WorkspaceMetadata } from "./types/workspace";
77

8-
export const CONFIG_DIR = path.join(os.homedir(), ".cmux");
9-
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
10-
const PROVIDERS_FILE = path.join(CONFIG_DIR, "providers.jsonc");
11-
export const SESSIONS_DIR = path.join(CONFIG_DIR, "sessions");
12-
138
export interface Workspace {
149
branch: string;
1510
path: string;
@@ -20,7 +15,7 @@ export interface ProjectConfig {
2015
workspaces: Workspace[];
2116
}
2217

23-
export interface Config {
18+
export interface ProjectsConfig {
2419
projects: Map<string, ProjectConfig>;
2520
}
2621

@@ -32,164 +27,185 @@ export interface ProviderConfig {
3227

3328
export type ProvidersConfig = Record<string, ProviderConfig>;
3429

35-
export function load_config_or_default(): Config {
36-
try {
37-
if (fs.existsSync(CONFIG_FILE)) {
38-
const data = fs.readFileSync(CONFIG_FILE, "utf-8");
39-
const parsed = JSON.parse(data) as { projects?: unknown };
40-
41-
// Config is stored as array of [path, config] pairs
42-
if (parsed.projects && Array.isArray(parsed.projects)) {
43-
const projectsMap = new Map<string, ProjectConfig>(
44-
parsed.projects as [string, ProjectConfig][]
45-
);
46-
return {
47-
projects: projectsMap,
48-
};
49-
}
50-
}
51-
} catch (error) {
52-
console.error("Error loading config:", error);
30+
/**
31+
* Config - Centralized configuration management
32+
*
33+
* Encapsulates all config paths and operations, making them dependency-injectable
34+
* and testable. Pass a custom rootDir for tests to avoid polluting ~/.cmux
35+
*/
36+
export class Config {
37+
readonly rootDir: string;
38+
readonly sessionsDir: string;
39+
readonly srcDir: string;
40+
private readonly configFile: string;
41+
private readonly providersFile: string;
42+
43+
constructor(rootDir?: string) {
44+
this.rootDir = rootDir ?? path.join(os.homedir(), ".cmux");
45+
this.sessionsDir = path.join(this.rootDir, "sessions");
46+
this.srcDir = path.join(this.rootDir, "src");
47+
this.configFile = path.join(this.rootDir, "config.json");
48+
this.providersFile = path.join(this.rootDir, "providers.jsonc");
5349
}
5450

55-
// Return default config
56-
return {
57-
projects: new Map(),
58-
};
59-
}
60-
61-
export function save_config(config: Config): void {
62-
try {
63-
if (!fs.existsSync(CONFIG_DIR)) {
64-
fs.mkdirSync(CONFIG_DIR, { recursive: true });
51+
loadConfigOrDefault(): ProjectsConfig {
52+
try {
53+
if (fs.existsSync(this.configFile)) {
54+
const data = fs.readFileSync(this.configFile, "utf-8");
55+
const parsed = JSON.parse(data) as { projects?: unknown };
56+
57+
// Config is stored as array of [path, config] pairs
58+
if (parsed.projects && Array.isArray(parsed.projects)) {
59+
const projectsMap = new Map<string, ProjectConfig>(
60+
parsed.projects as [string, ProjectConfig][]
61+
);
62+
return {
63+
projects: projectsMap,
64+
};
65+
}
66+
}
67+
} catch (error) {
68+
console.error("Error loading config:", error);
6569
}
6670

67-
const data = {
68-
projects: Array.from(config.projects.entries()),
71+
// Return default config
72+
return {
73+
projects: new Map(),
6974
};
70-
71-
fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2));
72-
} catch (error) {
73-
console.error("Error saving config:", error);
7475
}
75-
}
76-
77-
export function getProjectName(projectPath: string): string {
78-
return projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown";
79-
}
8076

81-
export function getWorkspacePath(projectPath: string, branch: string): string {
82-
const projectName = getProjectName(projectPath);
83-
return path.join(CONFIG_DIR, "src", projectName, branch);
84-
}
85-
86-
/**
87-
* Find a workspace path by project name and branch
88-
* @returns The workspace path or null if not found
89-
*/
90-
export function findWorkspacePath(projectName: string, branch: string): string | null {
91-
const config = load_config_or_default();
77+
saveConfig(config: ProjectsConfig): void {
78+
try {
79+
if (!fs.existsSync(this.rootDir)) {
80+
fs.mkdirSync(this.rootDir, { recursive: true });
81+
}
9282

93-
for (const [projectPath, project] of config.projects) {
94-
const currentProjectName = path.basename(projectPath);
83+
const data = {
84+
projects: Array.from(config.projects.entries()),
85+
};
9586

96-
if (currentProjectName === projectName) {
97-
const workspace = project.workspaces.find((w: Workspace) => w.branch === branch);
98-
if (workspace) {
99-
return workspace.path;
100-
}
87+
fs.writeFileSync(this.configFile, JSON.stringify(data, null, 2));
88+
} catch (error) {
89+
console.error("Error saving config:", error);
10190
}
10291
}
10392

104-
return null;
105-
}
106-
107-
/**
108-
* WARNING: Never try to derive workspace path from workspace ID!
109-
* This is a code smell that leads to bugs.
110-
*
111-
* The workspace path should always:
112-
* 1. Be stored in WorkspaceMetadata when the workspace is created
113-
* 2. Be retrieved from WorkspaceMetadata when needed
114-
* 3. Be passed through the call stack explicitly
115-
*
116-
* Parsing workspaceId strings to derive paths is fragile and error-prone.
117-
* The workspace path is established when the git worktree is created,
118-
* and that canonical path should be preserved and used throughout.
119-
*/
93+
private getProjectName(projectPath: string): string {
94+
return projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown";
95+
}
12096

121-
/**
122-
* Get the session directory for a specific workspace
123-
*/
124-
export function getSessionDir(workspaceId: string): string {
125-
return path.join(SESSIONS_DIR, workspaceId);
126-
}
97+
getWorkspacePath(projectPath: string, branch: string): string {
98+
const projectName = this.getProjectName(projectPath);
99+
return path.join(this.srcDir, projectName, branch);
100+
}
127101

128-
/**
129-
* Get all workspace metadata by scanning sessions directory and loading metadata files
130-
* This centralizes the logic for workspace discovery and metadata loading
131-
*/
132-
export async function getAllWorkspaceMetadata(): Promise<
133-
{ workspaceId: string; metadata: WorkspaceMetadata }[]
134-
> {
135-
try {
136-
// Scan sessions directory for workspace directories
137-
await fsPromises.access(SESSIONS_DIR);
138-
const entries = await fsPromises.readdir(SESSIONS_DIR, { withFileTypes: true });
139-
const workspaceIds = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
140-
141-
const workspaceMetadata: { workspaceId: string; metadata: WorkspaceMetadata }[] = [];
142-
143-
for (const workspaceId of workspaceIds) {
144-
try {
145-
const metadataPath = path.join(getSessionDir(workspaceId), "metadata.json");
146-
const data = await fsPromises.readFile(metadataPath, "utf-8");
147-
const metadata = JSON.parse(data) as WorkspaceMetadata;
148-
workspaceMetadata.push({ workspaceId, metadata });
149-
} catch (error) {
150-
// Skip workspaces with missing or invalid metadata
151-
console.warn(`Failed to load metadata for workspace ${workspaceId}:`, error);
102+
/**
103+
* Find a workspace path by project name and branch
104+
* @returns The workspace path or null if not found
105+
*/
106+
findWorkspacePath(projectName: string, branch: string): string | null {
107+
const config = this.loadConfigOrDefault();
108+
109+
for (const [projectPath, project] of config.projects) {
110+
const currentProjectName = path.basename(projectPath);
111+
112+
if (currentProjectName === projectName) {
113+
const workspace = project.workspaces.find((w: Workspace) => w.branch === branch);
114+
if (workspace) {
115+
return workspace.path;
116+
}
152117
}
153118
}
154119

155-
return workspaceMetadata;
156-
} catch {
157-
return []; // Sessions directory doesn't exist yet
120+
return null;
158121
}
159-
}
160122

161-
/**
162-
* Load providers configuration from JSONC file
163-
* Supports comments in JSONC format
164-
*/
165-
export function loadProvidersConfig(): ProvidersConfig | null {
166-
try {
167-
if (fs.existsSync(PROVIDERS_FILE)) {
168-
const data = fs.readFileSync(PROVIDERS_FILE, "utf-8");
169-
return jsonc.parse(data) as ProvidersConfig;
170-
}
171-
} catch (error) {
172-
console.error("Error loading providers config:", error);
123+
/**
124+
* WARNING: Never try to derive workspace path from workspace ID!
125+
* This is a code smell that leads to bugs.
126+
*
127+
* The workspace path should always:
128+
* 1. Be stored in WorkspaceMetadata when the workspace is created
129+
* 2. Be retrieved from WorkspaceMetadata when needed
130+
* 3. Be passed through the call stack explicitly
131+
*
132+
* Parsing workspaceId strings to derive paths is fragile and error-prone.
133+
* The workspace path is established when the git worktree is created,
134+
* and that canonical path should be preserved and used throughout.
135+
*/
136+
137+
/**
138+
* Get the session directory for a specific workspace
139+
*/
140+
getSessionDir(workspaceId: string): string {
141+
return path.join(this.sessionsDir, workspaceId);
173142
}
174143

175-
return null;
176-
}
144+
/**
145+
* Get all workspace metadata by scanning sessions directory and loading metadata files
146+
* This centralizes the logic for workspace discovery and metadata loading
147+
*/
148+
async getAllWorkspaceMetadata(): Promise<{ workspaceId: string; metadata: WorkspaceMetadata }[]> {
149+
try {
150+
// Scan sessions directory for workspace directories
151+
await fsPromises.access(this.sessionsDir);
152+
const entries = await fsPromises.readdir(this.sessionsDir, { withFileTypes: true });
153+
const workspaceIds = entries
154+
.filter((entry) => entry.isDirectory())
155+
.map((entry) => entry.name);
156+
157+
const workspaceMetadata: { workspaceId: string; metadata: WorkspaceMetadata }[] = [];
158+
159+
for (const workspaceId of workspaceIds) {
160+
try {
161+
const metadataPath = path.join(this.getSessionDir(workspaceId), "metadata.json");
162+
const data = await fsPromises.readFile(metadataPath, "utf-8");
163+
const metadata = JSON.parse(data) as WorkspaceMetadata;
164+
workspaceMetadata.push({ workspaceId, metadata });
165+
} catch (error) {
166+
// Skip workspaces with missing or invalid metadata
167+
console.warn(`Failed to load metadata for workspace ${workspaceId}:`, error);
168+
}
169+
}
177170

178-
/**
179-
* Save providers configuration to JSONC file
180-
* @param config The providers configuration to save
181-
*/
182-
export function saveProvidersConfig(config: ProvidersConfig): void {
183-
try {
184-
if (!fs.existsSync(CONFIG_DIR)) {
185-
fs.mkdirSync(CONFIG_DIR, { recursive: true });
171+
return workspaceMetadata;
172+
} catch {
173+
return []; // Sessions directory doesn't exist yet
174+
}
175+
}
176+
177+
/**
178+
* Load providers configuration from JSONC file
179+
* Supports comments in JSONC format
180+
*/
181+
loadProvidersConfig(): ProvidersConfig | null {
182+
try {
183+
if (fs.existsSync(this.providersFile)) {
184+
const data = fs.readFileSync(this.providersFile, "utf-8");
185+
return jsonc.parse(data) as ProvidersConfig;
186+
}
187+
} catch (error) {
188+
console.error("Error loading providers config:", error);
186189
}
187190

188-
// Format with 2-space indentation for readability
189-
const jsonString = JSON.stringify(config, null, 2);
191+
return null;
192+
}
193+
194+
/**
195+
* Save providers configuration to JSONC file
196+
* @param config The providers configuration to save
197+
*/
198+
saveProvidersConfig(config: ProvidersConfig): void {
199+
try {
200+
if (!fs.existsSync(this.rootDir)) {
201+
fs.mkdirSync(this.rootDir, { recursive: true });
202+
}
190203

191-
// Add a comment header to the file
192-
const contentWithComments = `// Providers configuration for cmux
204+
// Format with 2-space indentation for readability
205+
const jsonString = JSON.stringify(config, null, 2);
206+
207+
// Add a comment header to the file
208+
const contentWithComments = `// Providers configuration for cmux
193209
// Configure your AI providers here
194210
// Example:
195211
// {
@@ -200,9 +216,13 @@ export function saveProvidersConfig(config: ProvidersConfig): void {
200216
// }
201217
${jsonString}`;
202218

203-
fs.writeFileSync(PROVIDERS_FILE, contentWithComments);
204-
} catch (error) {
205-
console.error("Error saving providers config:", error);
206-
throw error; // Re-throw to let caller handle
219+
fs.writeFileSync(this.providersFile, contentWithComments);
220+
} catch (error) {
221+
console.error("Error saving providers config:", error);
222+
throw error; // Re-throw to let caller handle
223+
}
207224
}
208225
}
226+
227+
// Default instance for application use
228+
export const defaultConfig = new Config();

src/debug/costs.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as fs from "fs";
22
import * as path from "path";
3-
import { getSessionDir } from "../config";
3+
import { defaultConfig } from "../config";
44
import { CmuxMessage } from "../types/message";
55
import { calculateTokenStats } from "../utils/tokenStatsCalculator";
66

@@ -12,7 +12,7 @@ export async function costsCommand(workspaceId: string) {
1212
console.log(`\n=== Cost Statistics for workspace: ${workspaceId} ===\n`);
1313

1414
// Load chat history
15-
const sessionDir = getSessionDir(workspaceId);
15+
const sessionDir = defaultConfig.getSessionDir(workspaceId);
1616
const chatHistoryPath = path.join(sessionDir, "chat.jsonl");
1717

1818
if (!fs.existsSync(chatHistoryPath)) {

src/debug/list-workspaces.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { load_config_or_default, findWorkspacePath } from "../config";
1+
import { defaultConfig } from "../config";
22
import * as path from "path";
33
import * as fs from "fs";
44

55
export function listWorkspacesCommand() {
6-
const config = load_config_or_default();
6+
const config = defaultConfig.loadConfigOrDefault();
77

88
console.log("\n=== Configuration Debug ===\n");
99
console.log("Projects in config:", config.projects.size);
@@ -32,7 +32,7 @@ export function listWorkspacesCommand() {
3232
];
3333

3434
for (const test of testCases) {
35-
const result = findWorkspacePath(test.project, test.branch);
35+
const result = defaultConfig.findWorkspacePath(test.project, test.branch);
3636
console.log(`findWorkspacePath('${test.project}', '${test.branch}'):`);
3737
if (result) {
3838
console.log(` Found: ${result}`);

0 commit comments

Comments
 (0)