1+ /**
2+ * Copyright IBM Corp. 2025
3+ * Assisted by CursorAI
4+ */
5+
6+ import * as vscode from "vscode" ;
7+ import * as path from "path" ;
8+ import * as fs from "fs" ;
9+ import { StepZenError } from "../errors" ;
10+ import { Logger } from "./logger" ;
11+
12+ /**
13+ * Cache entry for a resolved project root
14+ */
15+ interface ProjectRootCache {
16+ /** The resolved project root path */
17+ projectRoot : string ;
18+ /** Timestamp when this was cached */
19+ timestamp : number ;
20+ /** The workspace folder this was resolved for */
21+ workspaceFolder ?: vscode . WorkspaceFolder ;
22+ /** The hint URI that was used for resolution */
23+ hintUri ?: vscode . Uri ;
24+ }
25+
26+ /**
27+ * Service for resolving StepZen project roots with caching support
28+ * Handles multi-root workspace scenarios and caches the last resolved root
29+ */
30+ export class ProjectResolver {
31+ private cache : ProjectRootCache | null = null ;
32+ private readonly cacheTimeout = 30000 ; // 30 seconds cache timeout
33+
34+ constructor ( private logger : Logger ) { }
35+
36+ /**
37+ * Resolves the root directory of a StepZen project with caching
38+ * First tries to find a project containing the active editor,
39+ * then scans the workspace, and finally prompts the user if multiple projects exist
40+ *
41+ * @param hintUri Optional URI hint to start searching from
42+ * @param forceRefresh If true, bypasses cache and forces fresh resolution
43+ * @returns Promise resolving to the absolute path of the project root
44+ * @throws StepZenError if no StepZen project is found or user cancels selection
45+ */
46+ async resolveStepZenProjectRoot (
47+ hintUri ?: vscode . Uri ,
48+ forceRefresh = false ,
49+ ) : Promise < string > {
50+ // Check cache first (unless force refresh is requested)
51+ if ( ! forceRefresh && this . isCacheValid ( hintUri ) ) {
52+ this . logger . debug ( `Using cached project root: ${ this . cache ! . projectRoot } ` ) ;
53+ return this . cache ! . projectRoot ;
54+ }
55+
56+ this . logger . debug ( "Resolving StepZen project root..." ) ;
57+
58+ // ① Try the folder that owns the active editor or hint URI
59+ const start = hintUri ?? vscode . window . activeTextEditor ?. document . uri ;
60+ if ( start ) {
61+ // Validate that the URI has a valid filesystem path
62+ if ( ! start . fsPath || typeof start . fsPath !== "string" ) {
63+ this . logger . warn ( "Invalid path in active editor URI" ) ;
64+ } else {
65+ const byAscend = this . ascendForConfig ( path . dirname ( start . fsPath ) ) ;
66+ if ( byAscend ) {
67+ this . updateCache ( byAscend , start ) ;
68+ return byAscend ;
69+ }
70+ }
71+ }
72+
73+ // ② Otherwise scan the workspace(s) for StepZen projects
74+ this . logger . info ( "StepZen project not found in current folder. Scanning workspace(s)..." ) ;
75+
76+ const configs = await this . findStepZenConfigs ( ) ;
77+ if ( ! configs || configs . length === 0 ) {
78+ throw new StepZenError (
79+ "No StepZen project (stepzen.config.json) found in workspace." ,
80+ "CONFIG_NOT_FOUND"
81+ ) ;
82+ }
83+
84+ if ( configs . length === 1 ) {
85+ if ( ! configs [ 0 ] . fsPath ) {
86+ throw new StepZenError (
87+ "Invalid file path for StepZen configuration." ,
88+ "INVALID_FILE_PATH"
89+ ) ;
90+ }
91+ const projectRoot = path . dirname ( configs [ 0 ] . fsPath ) ;
92+ this . updateCache ( projectRoot , hintUri ) ;
93+ return projectRoot ;
94+ }
95+
96+ // ③ Prompt when several projects exist
97+ this . logger . info ( "Multiple StepZen projects found. Prompting for selection..." ) ;
98+
99+ const projectRoot = await this . promptForProjectSelection ( configs ) ;
100+ this . updateCache ( projectRoot , hintUri ) ;
101+ return projectRoot ;
102+ }
103+
104+ /**
105+ * Clears the cached project root
106+ * Useful when workspace changes or project structure changes
107+ */
108+ clearCache ( ) : void {
109+ this . logger . debug ( "Clearing project root cache" ) ;
110+ this . cache = null ;
111+ }
112+
113+ /**
114+ * Gets the currently cached project root if valid
115+ * @returns The cached project root path or null if no valid cache
116+ */
117+ getCachedProjectRoot ( ) : string | null {
118+ if ( this . isCacheValid ( ) ) {
119+ return this . cache ! . projectRoot ;
120+ }
121+ return null ;
122+ }
123+
124+ /**
125+ * Checks if the current cache is valid for the given hint URI
126+ */
127+ private isCacheValid ( hintUri ?: vscode . Uri ) : boolean {
128+ if ( ! this . cache ) {
129+ return false ;
130+ }
131+
132+ // Check if cache has expired
133+ const now = Date . now ( ) ;
134+ if ( now - this . cache . timestamp > this . cacheTimeout ) {
135+ this . logger . debug ( "Project root cache expired" ) ;
136+ return false ;
137+ }
138+
139+ // If we have a hint URI, check if it's in the same workspace folder as cached
140+ if ( hintUri ) {
141+ const currentWorkspaceFolder = vscode . workspace . getWorkspaceFolder ( hintUri ) ;
142+ if ( currentWorkspaceFolder !== this . cache . workspaceFolder ) {
143+ this . logger . debug ( "Hint URI is in different workspace folder than cached" ) ;
144+ return false ;
145+ }
146+ }
147+
148+ // Verify the cached project root still exists and has a config file
149+ const configPath = path . join ( this . cache . projectRoot , "stepzen.config.json" ) ;
150+ if ( ! fs . existsSync ( configPath ) ) {
151+ this . logger . debug ( "Cached project root no longer contains stepzen.config.json" ) ;
152+ return false ;
153+ }
154+
155+ return true ;
156+ }
157+
158+ /**
159+ * Updates the cache with a new project root
160+ */
161+ private updateCache ( projectRoot : string , hintUri ?: vscode . Uri ) : void {
162+ const workspaceFolder = hintUri ? vscode . workspace . getWorkspaceFolder ( hintUri ) : undefined ;
163+
164+ this . cache = {
165+ projectRoot,
166+ timestamp : Date . now ( ) ,
167+ workspaceFolder,
168+ hintUri,
169+ } ;
170+
171+ this . logger . debug ( `Cached project root: ${ projectRoot } ` ) ;
172+ }
173+
174+ /**
175+ * Finds all StepZen configuration files in the workspace(s)
176+ * Supports multi-root workspaces by searching in all workspace folders
177+ */
178+ private async findStepZenConfigs ( ) : Promise < vscode . Uri [ ] > {
179+ const configs : vscode . Uri [ ] = [ ] ;
180+
181+ // Handle multi-root workspaces
182+ if ( vscode . workspace . workspaceFolders ) {
183+ for ( const folder of vscode . workspace . workspaceFolders ) {
184+ try {
185+ const folderConfigs = await vscode . workspace . findFiles (
186+ new vscode . RelativePattern ( folder , "**/stepzen.config.json" ) ,
187+ new vscode . RelativePattern ( folder , "**/node_modules/**" ) ,
188+ ) ;
189+ configs . push ( ...folderConfigs ) ;
190+ } catch ( err ) {
191+ this . logger . warn ( `Failed to search for configs in workspace folder ${ folder . name } : ${ err } ` ) ;
192+ }
193+ }
194+ } else {
195+ // Fallback for single workspace
196+ const allConfigs = await vscode . workspace . findFiles (
197+ "**/stepzen.config.json" ,
198+ "**/node_modules/**" ,
199+ ) ;
200+ configs . push ( ...allConfigs ) ;
201+ }
202+
203+ return configs ;
204+ }
205+
206+ /**
207+ * Prompts user to select from multiple StepZen projects
208+ */
209+ private async promptForProjectSelection ( configs : vscode . Uri [ ] ) : Promise < string > {
210+ // Validate that all configs have valid paths
211+ const validConfigs = configs . filter (
212+ ( c ) => c . fsPath && typeof c . fsPath === "string" ,
213+ ) ;
214+
215+ if ( validConfigs . length === 0 ) {
216+ throw new StepZenError (
217+ "No valid StepZen project paths found." ,
218+ "INVALID_PROJECT_PATHS"
219+ ) ;
220+ }
221+
222+ // Create pick items with workspace folder context for multi-root workspaces
223+ const pickItems = validConfigs . map ( ( c ) => {
224+ const projectDir = path . dirname ( c . fsPath ) ;
225+ const workspaceFolder = vscode . workspace . getWorkspaceFolder ( c ) ;
226+
227+ let label : string ;
228+ if ( workspaceFolder && vscode . workspace . workspaceFolders && vscode . workspace . workspaceFolders . length > 1 ) {
229+ // Multi-root workspace: include workspace folder name
230+ const relativePath = vscode . workspace . asRelativePath ( projectDir , false ) ;
231+ label = `${ workspaceFolder . name } : ${ relativePath } ` ;
232+ } else {
233+ // Single workspace: just show relative path
234+ label = vscode . workspace . asRelativePath ( projectDir , false ) ;
235+ }
236+
237+ return {
238+ label,
239+ target : projectDir ,
240+ description : workspaceFolder ? `in ${ workspaceFolder . name } ` : undefined ,
241+ } ;
242+ } ) ;
243+
244+ const pick = await vscode . window . showQuickPick ( pickItems , {
245+ placeHolder : "Select the StepZen project to use" ,
246+ matchOnDescription : true ,
247+ } ) ;
248+
249+ if ( ! pick || ! pick . target ) {
250+ throw new StepZenError (
251+ "Operation cancelled by user." ,
252+ "USER_CANCELLED"
253+ ) ;
254+ }
255+
256+ return pick . target ;
257+ }
258+
259+ /**
260+ * Helper function that ascends directory tree looking for stepzen.config.json
261+ * @param dir Starting directory path
262+ * @returns Path to directory containing stepzen.config.json or null if not found
263+ */
264+ private ascendForConfig ( dir : string ) : string | null {
265+ // Validate input
266+ if ( ! dir || typeof dir !== "string" ) {
267+ this . logger . error ( "Invalid directory path provided to ascendForConfig" ) ;
268+ return null ;
269+ }
270+
271+ try {
272+ while ( true ) {
273+ const configPath = path . join ( dir , "stepzen.config.json" ) ;
274+ if ( fs . existsSync ( configPath ) ) {
275+ return dir ;
276+ }
277+ const parent = path . dirname ( dir ) ;
278+ if ( parent === dir ) {
279+ break ;
280+ }
281+ dir = parent ;
282+ }
283+ } catch ( err ) {
284+ this . logger . error ( "Failed to search for StepZen configuration in directory tree" , err ) ;
285+ return null ;
286+ }
287+
288+ return null ;
289+ }
290+ }
0 commit comments