refactor: add shared path and version utilities

Extract common utilities to src/shared/pathUtils.ts:
- expandTilde: tilde expansion for paths
- parseVersion: parse semver strings to arrays
- compareVersions: compare version strings (1/-1/0)

These consolidate duplicated logic found across multiple files.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Timm Stokke
2025-12-29 21:11:14 -08:00
committed by Pedram Amini
parent 11c12e5b02
commit 9eca0dc054
3 changed files with 273 additions and 0 deletions

View File

@@ -0,0 +1,167 @@
/**
* Tests for shared path and version utility functions
*
* @file src/shared/pathUtils.ts
*
* These utilities consolidate duplicated logic found across:
* - agent-detector.ts (expandTilde)
* - ssh-command-builder.ts (expandPath)
* - ssh-config-parser.ts (expandPath)
* - ssh-remote-manager.ts (expandPath)
* - process-manager.ts (inline tilde expansion)
* - update-checker.ts (version comparison)
*/
import { describe, it, expect, vi } from 'vitest';
import * as os from 'os';
import { expandTilde, parseVersion, compareVersions } from '../../shared/pathUtils';
// Mock os.homedir for consistent test behavior
vi.mock('os', async () => {
const actual = await vi.importActual<typeof os>('os');
return {
...actual,
homedir: vi.fn(() => '/Users/testuser'),
};
});
describe('expandTilde', () => {
describe('basic tilde expansion', () => {
it('should expand ~/path to home directory + path', () => {
expect(expandTilde('~/Documents')).toBe('/Users/testuser/Documents');
});
it('should expand ~ alone to home directory', () => {
expect(expandTilde('~')).toBe('/Users/testuser');
});
it('should expand ~/path/to/file correctly', () => {
expect(expandTilde('~/.ssh/id_rsa')).toBe('/Users/testuser/.ssh/id_rsa');
});
it('should preserve paths without tilde', () => {
expect(expandTilde('/absolute/path')).toBe('/absolute/path');
expect(expandTilde('relative/path')).toBe('relative/path');
expect(expandTilde('./local/path')).toBe('./local/path');
});
it('should not expand tilde in middle of path', () => {
expect(expandTilde('/path/with~tilde')).toBe('/path/with~tilde');
});
});
describe('edge cases', () => {
it('should handle empty string', () => {
expect(expandTilde('')).toBe('');
});
it('should handle paths with spaces', () => {
expect(expandTilde('~/My Documents/file.txt')).toBe('/Users/testuser/My Documents/file.txt');
});
it('should handle deeply nested paths', () => {
expect(expandTilde('~/.local/share/fnm/node-versions/v20.0.0/installation/bin'))
.toBe('/Users/testuser/.local/share/fnm/node-versions/v20.0.0/installation/bin');
});
});
describe('cross-platform consistency', () => {
it('should handle Windows-style home (when provided)', () => {
vi.mocked(os.homedir).mockReturnValue('C:\\Users\\testuser');
const result = expandTilde('~/.config');
expect(result).toContain('testuser');
expect(result).toContain('.config');
});
});
});
describe('parseVersion', () => {
it('should parse version with v prefix', () => {
expect(parseVersion('v22.10.0')).toEqual([22, 10, 0]);
});
it('should parse version without v prefix', () => {
expect(parseVersion('0.14.0')).toEqual([0, 14, 0]);
});
it('should handle single digit versions', () => {
expect(parseVersion('v8.0.0')).toEqual([8, 0, 0]);
});
it('should handle versions with more than 3 parts', () => {
expect(parseVersion('1.2.3.4')).toEqual([1, 2, 3, 4]);
});
it('should handle non-numeric parts as 0', () => {
expect(parseVersion('1.beta.3')).toEqual([1, 0, 3]);
});
});
describe('compareVersions', () => {
describe('basic version comparison', () => {
it('should return 1 when a > b', () => {
expect(compareVersions('v22.0.0', 'v20.0.0')).toBe(1);
});
it('should return -1 when a < b', () => {
expect(compareVersions('v20.0.0', 'v22.0.0')).toBe(-1);
});
it('should return 0 for equal versions', () => {
expect(compareVersions('v20.10.0', 'v20.10.0')).toBe(0);
});
});
describe('minor version comparison', () => {
it('should compare by minor version when major is equal', () => {
expect(compareVersions('v20.11.0', 'v20.10.0')).toBe(1);
expect(compareVersions('v20.10.0', 'v20.11.0')).toBe(-1);
});
it('should handle v18.20 vs v18.2 correctly (20 > 2)', () => {
expect(compareVersions('v18.20.0', 'v18.2.0')).toBe(1);
});
});
describe('patch version comparison', () => {
it('should compare by patch when major and minor are equal', () => {
expect(compareVersions('v20.10.5', 'v20.10.3')).toBe(1);
expect(compareVersions('v20.10.3', 'v20.10.5')).toBe(-1);
});
});
describe('array sorting', () => {
it('should sort ascending when used directly', () => {
const versions = ['v22.21.0', 'v18.17.0', 'v20.10.0'];
const sorted = [...versions].sort(compareVersions);
expect(sorted).toEqual(['v18.17.0', 'v20.10.0', 'v22.21.0']);
});
it('should sort descending when args are flipped', () => {
const versions = ['v18.17.0', 'v22.21.0', 'v20.10.0', 'v18.2.0', 'v21.0.0'];
const sorted = [...versions].sort((a, b) => compareVersions(b, a));
expect(sorted).toEqual(['v22.21.0', 'v21.0.0', 'v20.10.0', 'v18.17.0', 'v18.2.0']);
});
it('should handle single-digit versions', () => {
const versions = ['v8.0.0', 'v16.0.0', 'v4.0.0', 'v12.0.0'];
const sorted = [...versions].sort((a, b) => compareVersions(b, a));
expect(sorted).toEqual(['v16.0.0', 'v12.0.0', 'v8.0.0', 'v4.0.0']);
});
});
describe('edge cases', () => {
it('should handle versions without v prefix', () => {
expect(compareVersions('22.0.0', '20.0.0')).toBe(1);
});
it('should handle mixed v prefix', () => {
expect(compareVersions('v22.0.0', '20.0.0')).toBe(1);
});
it('should handle versions with different part counts', () => {
expect(compareVersions('1.0', '1.0.0')).toBe(0);
expect(compareVersions('1.0.1', '1.0')).toBe(1);
});
});
});

View File

@@ -13,3 +13,4 @@ export * from './gitUtils';
export * from './emojiUtils';
export * from './treeUtils';
export * from './stringUtils';
export * from './pathUtils';

105
src/shared/pathUtils.ts Normal file
View File

@@ -0,0 +1,105 @@
/**
* Shared path and version utility functions
*
* This module provides utilities used across multiple parts of the application.
*
* Consolidates duplicated logic from:
* - agent-detector.ts (expandTilde)
* - ssh-command-builder.ts (expandPath)
* - ssh-config-parser.ts (expandPath)
* - ssh-remote-manager.ts (expandPath)
* - process-manager.ts (inline tilde expansion)
* - update-checker.ts (version comparison)
*/
import * as os from 'os';
import * as path from 'path';
/**
* Expand tilde (~) to home directory in paths.
*
* Node.js fs functions don't understand shell tilde expansion,
* so this function provides consistent tilde handling across the codebase.
*
* @param filePath - Path that may start with ~ or ~/
* @returns Expanded absolute path with ~ replaced by home directory
*
* @example
* ```typescript
* expandTilde('~/.ssh/id_rsa') // '/Users/username/.ssh/id_rsa'
* expandTilde('~') // '/Users/username'
* expandTilde('/absolute/path') // '/absolute/path' (unchanged)
* ```
*/
export function expandTilde(filePath: string): string {
if (!filePath) {
return filePath;
}
if (filePath === '~') {
return os.homedir();
}
if (filePath.startsWith('~/')) {
return path.join(os.homedir(), filePath.slice(2));
}
return filePath;
}
/**
* Parse version string to comparable array of numbers.
*
* @param version - Version string (e.g., "v22.10.0" or "0.14.0")
* @returns Array of version numbers (e.g., [22, 10, 0])
*
* @example
* ```typescript
* parseVersion('v22.10.0') // [22, 10, 0]
* parseVersion('0.14.0') // [0, 14, 0]
* ```
*/
export function parseVersion(version: string): number[] {
const cleaned = version.replace(/^v/, '');
return cleaned.split('.').map(n => parseInt(n, 10) || 0);
}
/**
* Compare two version strings.
*
* Returns: 1 if a > b, -1 if a < b, 0 if equal.
* Handles versions with or without 'v' prefix.
*
* @param a - First version string
* @param b - Second version string
* @returns 1 if a > b, -1 if a < b, 0 if equal
*
* @example
* ```typescript
* compareVersions('v22.0.0', 'v20.0.0') // 1 (a > b)
* compareVersions('v18.0.0', 'v20.0.0') // -1 (a < b)
* compareVersions('v20.0.0', 'v20.0.0') // 0 (equal)
*
* // For descending sort (highest first):
* versions.sort((a, b) => compareVersions(b, a))
*
* // For ascending sort (lowest first):
* versions.sort(compareVersions)
* ```
*/
export function compareVersions(a: string, b: string): number {
const partsA = parseVersion(a);
const partsB = parseVersion(b);
const maxLength = Math.max(partsA.length, partsB.length);
for (let i = 0; i < maxLength; i++) {
const numA = partsA[i] || 0;
const numB = partsB[i] || 0;
if (numA > numB) return 1;
if (numA < numB) return -1;
}
return 0;
}