@@ -2,10 +2,15 @@ import * as fs from 'node:fs'
22import * as os from 'node:os'
33import * as path from 'node:path'
44import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
5- import { ConfigLoader , DEFAULT_CONFIG_FILE_NAME , DEFAULT_GLOBAL_CONFIG_DIR , loadUserConfig } from './ConfigLoader'
5+ import { ConfigLoader , DEFAULT_CONFIG_FILE_NAME , DEFAULT_GLOBAL_CONFIG_DIR , ensureConfigLink , loadUserConfig } from './ConfigLoader'
66
77vi . mock ( 'node:fs' ) // Mock fs module
88vi . mock ( 'node:os' )
9+ vi . mock ( '@truenine/desk-paths' , ( ) => ( {
10+ isSymlink : vi . fn ( ) ,
11+ readSymlinkTarget : vi . fn ( ) ,
12+ deletePathSync : vi . fn ( )
13+ } ) )
914
1015describe ( 'configLoader' , ( ) => {
1116 const mockHomedir = '/home/testuser'
@@ -328,3 +333,135 @@ describe('configLoader', () => {
328333 } )
329334 } )
330335} )
336+
337+ describe ( 'ensureConfigLink' , ( ) => {
338+ let deskPaths : typeof import ( '@truenine/desk-paths' )
339+
340+ const LOCAL = '/shadow/.tnmsc.json'
341+ const GLOBAL = '/home/testuser/.aindex/.tnmsc.json'
342+
343+ const logger = {
344+ trace : vi . fn ( ) ,
345+ debug : vi . fn ( ) ,
346+ info : vi . fn ( ) ,
347+ warn : vi . fn ( ) ,
348+ error : vi . fn ( ) ,
349+ fatal : vi . fn ( )
350+ }
351+
352+ beforeEach ( async ( ) => {
353+ deskPaths = await import ( '@truenine/desk-paths' )
354+ vi . mocked ( os . homedir ) . mockReturnValue ( '/home/testuser' )
355+ vi . mocked ( fs . existsSync ) . mockReturnValue ( false )
356+ vi . mocked ( fs . symlinkSync ) . mockImplementation ( ( ) => void 0 )
357+ vi . mocked ( fs . copyFileSync ) . mockImplementation ( ( ) => void 0 )
358+ vi . mocked ( deskPaths . isSymlink ) . mockReturnValue ( false )
359+ vi . mocked ( deskPaths . readSymlinkTarget ) . mockReturnValue ( null )
360+ vi . mocked ( deskPaths . deletePathSync ) . mockImplementation ( ( ) => void 0 )
361+ } )
362+
363+ afterEach ( ( ) => vi . clearAllMocks ( ) )
364+
365+ it ( 'no-op when global config does not exist' , ( ) => {
366+ vi . mocked ( fs . existsSync ) . mockReturnValue ( false )
367+
368+ ensureConfigLink ( LOCAL , GLOBAL , logger )
369+
370+ expect ( fs . symlinkSync ) . not . toHaveBeenCalled ( )
371+ expect ( fs . copyFileSync ) . not . toHaveBeenCalled ( )
372+ } )
373+
374+ it ( 'creates symlink when local file does not exist' , ( ) => {
375+ vi . mocked ( fs . existsSync ) . mockImplementation ( p => p === GLOBAL )
376+ vi . mocked ( deskPaths . isSymlink ) . mockReturnValue ( false )
377+
378+ ensureConfigLink ( LOCAL , GLOBAL , logger )
379+
380+ expect ( fs . symlinkSync ) . toHaveBeenCalledWith ( GLOBAL , LOCAL , 'file' )
381+ } )
382+
383+ it ( 'no-op when local is a correct symlink pointing to global' , ( ) => {
384+ vi . mocked ( fs . existsSync ) . mockImplementation ( p => p === GLOBAL || p === LOCAL )
385+ vi . mocked ( deskPaths . isSymlink ) . mockReturnValue ( true )
386+ vi . mocked ( deskPaths . readSymlinkTarget ) . mockReturnValue ( GLOBAL )
387+
388+ ensureConfigLink ( LOCAL , GLOBAL , logger )
389+
390+ expect ( fs . symlinkSync ) . not . toHaveBeenCalled ( )
391+ expect ( deskPaths . deletePathSync ) . not . toHaveBeenCalled ( )
392+ } )
393+
394+ it ( 'deletes stale symlink and recreates when target differs' , ( ) => {
395+ vi . mocked ( fs . existsSync ) . mockImplementation ( p => p === GLOBAL || p === LOCAL )
396+ vi . mocked ( deskPaths . isSymlink ) . mockReturnValue ( true )
397+ vi . mocked ( deskPaths . readSymlinkTarget ) . mockReturnValue ( '/other/path/.tnmsc.json' )
398+
399+ ensureConfigLink ( LOCAL , GLOBAL , logger )
400+
401+ expect ( deskPaths . deletePathSync ) . toHaveBeenCalledWith ( LOCAL )
402+ expect ( fs . symlinkSync ) . toHaveBeenCalledWith ( GLOBAL , LOCAL , 'file' )
403+ } )
404+
405+ it ( 'syncs regular file back to global when local is newer, then recreates symlink' , ( ) => {
406+ vi . mocked ( fs . existsSync ) . mockImplementation ( p => p === GLOBAL || p === LOCAL )
407+ vi . mocked ( deskPaths . isSymlink ) . mockReturnValue ( false )
408+ vi . mocked ( fs . statSync ) . mockImplementation ( p => {
409+ if ( p === LOCAL ) return { mtimeMs : 2000 } as fs . Stats
410+ return { mtimeMs : 1000 } as fs . Stats
411+ } )
412+
413+ ensureConfigLink ( LOCAL , GLOBAL , logger )
414+
415+ expect ( fs . copyFileSync ) . toHaveBeenCalledWith ( LOCAL , GLOBAL )
416+ expect ( deskPaths . deletePathSync ) . toHaveBeenCalledWith ( LOCAL )
417+ expect ( fs . symlinkSync ) . toHaveBeenCalledWith ( GLOBAL , LOCAL , 'file' )
418+ } )
419+
420+ it ( 'deletes regular file without sync-back when local is older than global' , ( ) => {
421+ vi . mocked ( fs . existsSync ) . mockImplementation ( p => p === GLOBAL || p === LOCAL )
422+ vi . mocked ( deskPaths . isSymlink ) . mockReturnValue ( false )
423+ vi . mocked ( fs . statSync ) . mockImplementation ( p => {
424+ if ( p === LOCAL ) return { mtimeMs : 500 } as fs . Stats
425+ return { mtimeMs : 1000 } as fs . Stats
426+ } )
427+
428+ ensureConfigLink ( LOCAL , GLOBAL , logger )
429+
430+ expect ( fs . copyFileSync ) . not . toHaveBeenCalledWith ( LOCAL , GLOBAL )
431+ expect ( deskPaths . deletePathSync ) . toHaveBeenCalledWith ( LOCAL )
432+ expect ( fs . symlinkSync ) . toHaveBeenCalledWith ( GLOBAL , LOCAL , 'file' )
433+ } )
434+
435+ it ( 'falls back to copy when symlink fails' , ( ) => {
436+ vi . mocked ( fs . existsSync ) . mockImplementation ( p => p === GLOBAL )
437+ vi . mocked ( deskPaths . isSymlink ) . mockReturnValue ( false )
438+ vi . mocked ( fs . symlinkSync ) . mockImplementation ( ( ) => {
439+ throw new Error ( 'EPERM: operation not permitted' )
440+ } )
441+
442+ ensureConfigLink ( LOCAL , GLOBAL , logger )
443+
444+ expect ( fs . copyFileSync ) . toHaveBeenCalledWith ( GLOBAL , LOCAL )
445+ expect ( logger . warn ) . toHaveBeenCalledWith (
446+ 'symlink unavailable, copied config (auto-sync disabled)' ,
447+ expect . objectContaining ( { dest : LOCAL } )
448+ )
449+ } )
450+
451+ it ( 'logs warn and does not throw when both symlink and copy fail' , ( ) => {
452+ vi . mocked ( fs . existsSync ) . mockImplementation ( p => p === GLOBAL )
453+ vi . mocked ( deskPaths . isSymlink ) . mockReturnValue ( false )
454+ vi . mocked ( fs . symlinkSync ) . mockImplementation ( ( ) => {
455+ throw new Error ( 'EPERM' )
456+ } )
457+ vi . mocked ( fs . copyFileSync ) . mockImplementation ( ( ) => {
458+ throw new Error ( 'ENOENT' )
459+ } )
460+
461+ expect ( ( ) => ensureConfigLink ( LOCAL , GLOBAL , logger ) ) . not . toThrow ( )
462+ expect ( logger . warn ) . toHaveBeenCalledWith (
463+ 'failed to link or copy config' ,
464+ expect . objectContaining ( { path : LOCAL , error : 'ENOENT' } )
465+ )
466+ } )
467+ } )
0 commit comments