intent_engine/
project.rs

1use crate::db::{create_pool, run_migrations};
2use crate::error::{IntentError, Result};
3use sqlx::SqlitePool;
4use std::path::PathBuf;
5
6const INTENT_DIR: &str = ".intent-engine";
7const DB_FILE: &str = "project.db";
8
9/// Project root markers in priority order (highest priority first)
10/// These are used to identify the root directory of a project
11const PROJECT_ROOT_MARKERS: &[&str] = &[
12    ".git",           // Git (highest priority)
13    ".hg",            // Mercurial
14    "package.json",   // Node.js
15    "Cargo.toml",     // Rust
16    "pyproject.toml", // Python (PEP 518)
17    "go.mod",         // Go Modules
18    "pom.xml",        // Maven (Java)
19    "build.gradle",   // Gradle (Java/Kotlin)
20];
21
22#[derive(Debug)]
23pub struct ProjectContext {
24    pub root: PathBuf,
25    pub db_path: PathBuf,
26    pub pool: SqlitePool,
27}
28
29impl ProjectContext {
30    /// Find the project root by searching upwards for .intent-engine directory
31    ///
32    /// Search strategy (in priority order):
33    /// 1. Check INTENT_ENGINE_PROJECT_DIR environment variable
34    /// 2. Search upwards from current directory for .intent-engine/
35    /// 3. Check user's home directory for .intent-engine/
36    pub fn find_project_root() -> Option<PathBuf> {
37        // Strategy 1: Check environment variable (highest priority)
38        if let Ok(env_path) = std::env::var("INTENT_ENGINE_PROJECT_DIR") {
39            let path = PathBuf::from(env_path);
40            let intent_dir = path.join(INTENT_DIR);
41            if intent_dir.exists() && intent_dir.is_dir() {
42                eprintln!(
43                    "✓ Using project from INTENT_ENGINE_PROJECT_DIR: {}",
44                    path.display()
45                );
46                return Some(path);
47            } else {
48                eprintln!(
49                    "⚠ INTENT_ENGINE_PROJECT_DIR set but no .intent-engine found: {}",
50                    path.display()
51                );
52            }
53        }
54
55        // Strategy 2: Search upwards from current directory (original behavior)
56        if let Ok(mut current) = std::env::current_dir() {
57            let start_dir = current.clone();
58            loop {
59                let intent_dir = current.join(INTENT_DIR);
60                if intent_dir.exists() && intent_dir.is_dir() {
61                    if current != start_dir {
62                        eprintln!("✓ Found project: {}", current.display());
63                    }
64                    return Some(current);
65                }
66
67                if !current.pop() {
68                    break;
69                }
70            }
71        }
72
73        // Strategy 3: Check user's home directory (fallback)
74        if let Ok(home) = std::env::var("HOME") {
75            let home_path = PathBuf::from(home);
76            let intent_dir = home_path.join(INTENT_DIR);
77            if intent_dir.exists() && intent_dir.is_dir() {
78                eprintln!("✓ Using home project: {}", home_path.display());
79                return Some(home_path);
80            }
81        }
82
83        // Windows: also check USERPROFILE
84        #[cfg(target_os = "windows")]
85        if let Ok(userprofile) = std::env::var("USERPROFILE") {
86            let home_path = PathBuf::from(userprofile);
87            let intent_dir = home_path.join(INTENT_DIR);
88            if intent_dir.exists() && intent_dir.is_dir() {
89                eprintln!("✓ Using home project: {}", home_path.display());
90                return Some(home_path);
91            }
92        }
93
94        None
95    }
96
97    /// Infer the project root directory based on common project markers
98    ///
99    /// This function implements a smart algorithm to find the project root:
100    /// 1. Start from current directory and traverse upwards
101    /// 2. Check each directory for project markers (in priority order)
102    /// 3. Return the first directory that contains any marker
103    /// 4. If no marker found, return None (fallback to CWD handled by caller)
104    fn infer_project_root() -> Option<PathBuf> {
105        let cwd = std::env::current_dir().ok()?;
106        let mut current = cwd.clone();
107
108        loop {
109            // Check if any marker exists in current directory
110            for marker in PROJECT_ROOT_MARKERS {
111                let marker_path = current.join(marker);
112                if marker_path.exists() {
113                    return Some(current);
114                }
115            }
116
117            // Try to move up to parent directory
118            if !current.pop() {
119                // Reached filesystem root without finding any marker
120                break;
121            }
122        }
123
124        None
125    }
126
127    /// Initialize a new Intent-Engine project using smart root inference
128    ///
129    /// This function implements the smart lazy initialization algorithm:
130    /// 1. Try to infer project root based on common markers
131    /// 2. If inference succeeds, initialize in the inferred root
132    /// 3. If inference fails, fallback to CWD and print warning to stderr
133    pub async fn initialize_project() -> Result<Self> {
134        let cwd = std::env::current_dir()?;
135
136        // Try to infer the project root
137        let root = match Self::infer_project_root() {
138            Some(inferred_root) => {
139                // Successfully inferred project root
140                inferred_root
141            },
142            None => {
143                // Fallback: use current working directory
144                // Print warning to stderr
145                eprintln!(
146                    "Warning: Could not determine a project root based on common markers (e.g., .git, package.json).\n\
147                     Initialized Intent-Engine in the current directory '{}'.\n\
148                     For predictable behavior, it's recommended to initialize from a directory containing a root marker.",
149                    cwd.display()
150                );
151                cwd
152            },
153        };
154
155        let intent_dir = root.join(INTENT_DIR);
156        let db_path = intent_dir.join(DB_FILE);
157
158        // Create .intent-engine directory if it doesn't exist
159        if !intent_dir.exists() {
160            std::fs::create_dir_all(&intent_dir)?;
161        }
162
163        // Create database connection
164        let pool = create_pool(&db_path).await?;
165
166        // Run migrations
167        run_migrations(&pool).await?;
168
169        Ok(ProjectContext {
170            root,
171            db_path,
172            pool,
173        })
174    }
175
176    /// Load an existing project context
177    pub async fn load() -> Result<Self> {
178        let root = Self::find_project_root().ok_or(IntentError::NotAProject)?;
179        let db_path = root.join(INTENT_DIR).join(DB_FILE);
180
181        let pool = create_pool(&db_path).await?;
182
183        Ok(ProjectContext {
184            root,
185            db_path,
186            pool,
187        })
188    }
189
190    /// Load project context, initializing if necessary (for write commands)
191    pub async fn load_or_init() -> Result<Self> {
192        match Self::load().await {
193            Ok(ctx) => Ok(ctx),
194            Err(IntentError::NotAProject) => Self::initialize_project().await,
195            Err(e) => Err(e),
196        }
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    // Note: Tests that modify the current directory are intentionally limited
205    // because they can interfere with other tests running in parallel.
206    // These functionalities are thoroughly tested by integration tests.
207
208    #[test]
209    fn test_constants() {
210        assert_eq!(INTENT_DIR, ".intent-engine");
211        assert_eq!(DB_FILE, "project.db");
212    }
213
214    #[test]
215    fn test_project_context_debug() {
216        // Just verify that ProjectContext implements Debug
217        // We can't easily create one without side effects in a unit test
218        let _type_check = |ctx: ProjectContext| {
219            let _ = format!("{:?}", ctx);
220        };
221    }
222
223    #[test]
224    fn test_project_root_markers_list() {
225        // Verify that the markers list contains expected markers
226        assert!(PROJECT_ROOT_MARKERS.contains(&".git"));
227        assert!(PROJECT_ROOT_MARKERS.contains(&"Cargo.toml"));
228        assert!(PROJECT_ROOT_MARKERS.contains(&"package.json"));
229    }
230
231    #[test]
232    fn test_project_root_markers_priority() {
233        // Verify that .git has highest priority (comes first)
234        assert_eq!(PROJECT_ROOT_MARKERS[0], ".git");
235    }
236
237    /// Test infer_project_root in an isolated environment
238    /// Note: This test creates a temporary directory structure but doesn't change CWD
239    #[test]
240    fn test_infer_project_root_with_git() {
241        // This test is limited because we can't easily change CWD in unit tests
242        // The actual behavior is tested in integration tests
243        // Here we just verify the marker list is correct
244        assert!(PROJECT_ROOT_MARKERS.contains(&".git"));
245    }
246
247    /// Test that markers list includes all major project types
248    #[test]
249    fn test_all_major_project_types_covered() {
250        let markers = PROJECT_ROOT_MARKERS;
251
252        // Git version control
253        assert!(markers.contains(&".git"));
254        assert!(markers.contains(&".hg"));
255
256        // Programming languages
257        assert!(markers.contains(&"Cargo.toml")); // Rust
258        assert!(markers.contains(&"package.json")); // Node.js
259        assert!(markers.contains(&"pyproject.toml")); // Python
260        assert!(markers.contains(&"go.mod")); // Go
261        assert!(markers.contains(&"pom.xml")); // Java (Maven)
262        assert!(markers.contains(&"build.gradle")); // Java/Kotlin (Gradle)
263    }
264}