foundry_mcp/cli/commands/
load_project.rs

1//! Implementation of the load_project command
2
3use crate::cli::args::LoadProjectArgs;
4use crate::core::{filesystem, project};
5use crate::types::responses::{FoundryResponse, LoadProjectResponse, ProjectContext};
6
7use anyhow::{Context, Result};
8use std::fs;
9
10pub async fn execute(args: LoadProjectArgs) -> Result<FoundryResponse<LoadProjectResponse>> {
11    // Validate project exists
12    validate_project_exists(&args.project_name)?;
13
14    // Load project data
15    let project_path = project::get_project_path(&args.project_name)?;
16    let project_context = load_project_context(&args.project_name, &project_path)?;
17    let specs_available = project_context.specs_available.clone();
18
19    // Build response
20    let response_data = LoadProjectResponse {
21        project: project_context,
22    };
23
24    // Determine validation status based on specs availability
25    let validation_status = if specs_available.is_empty() {
26        crate::types::responses::ValidationStatus::Incomplete
27    } else {
28        crate::types::responses::ValidationStatus::Complete
29    };
30
31    Ok(crate::types::responses::FoundryResponse {
32        data: response_data,
33        next_steps: generate_next_steps(&args.project_name, &specs_available),
34        validation_status,
35        workflow_hints: generate_workflow_hints(&specs_available),
36    })
37}
38
39/// Validate that project exists
40fn validate_project_exists(project_name: &str) -> Result<()> {
41    if !project::project_exists(project_name)? {
42        return Err(anyhow::anyhow!(
43            "Project '{}' not found. Use 'foundry list-projects' to see available projects.",
44            project_name
45        ));
46    }
47    Ok(())
48}
49
50/// Load project context from files
51fn load_project_context(
52    project_name: &str,
53    project_path: &std::path::Path,
54) -> Result<ProjectContext> {
55    // Read project files - handle missing files gracefully
56    let vision =
57        filesystem::read_file(project_path.join("vision.md")).unwrap_or_else(|_| String::new());
58    let tech_stack =
59        filesystem::read_file(project_path.join("tech-stack.md")).unwrap_or_else(|_| String::new());
60    let summary =
61        filesystem::read_file(project_path.join("summary.md")).unwrap_or_else(|_| String::new());
62
63    // Get creation time from directory metadata
64    let created_at = fs::metadata(project_path)
65        .and_then(|metadata| metadata.created())
66        .map_err(anyhow::Error::from)
67        .and_then(|time| {
68            time.duration_since(std::time::UNIX_EPOCH)
69                .map_err(anyhow::Error::from)
70        })
71        .map(|duration| {
72            chrono::DateTime::from_timestamp(duration.as_secs() as i64, 0)
73                .unwrap_or_else(chrono::Utc::now)
74                .to_rfc3339()
75        })
76        .unwrap_or_else(|_| chrono::Utc::now().to_rfc3339());
77
78    // Scan specs directory for available specifications
79    let specs_dir = project_path.join("specs");
80    let specs_available = if specs_dir.exists() {
81        fs::read_dir(&specs_dir)
82            .context("Failed to read specs directory")?
83            .filter_map(|entry| {
84                entry
85                    .ok()
86                    .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
87                    .map(|e| e.file_name().to_string_lossy().to_string())
88            })
89            .collect()
90    } else {
91        Vec::new()
92    };
93
94    Ok(ProjectContext {
95        name: project_name.to_string(),
96        vision,
97        tech_stack,
98        summary,
99        specs_available,
100        created_at,
101    })
102}
103
104/// Generate next steps based on available specs
105fn generate_next_steps(project_name: &str, specs_available: &[String]) -> Vec<String> {
106    if specs_available.is_empty() {
107        vec![
108            "Project context loaded successfully - ready for specification creation".to_string(),
109            format!(
110                "You can create your first specification: foundry create-spec {} <feature_name>",
111                project_name
112            ),
113            "Your loaded project context provides comprehensive background for development decisions".to_string(),
114        ]
115    } else {
116        vec![
117            format!(
118                "Project context loaded with {} specification(s) available",
119                specs_available.len()
120            ),
121            format!(
122                "You can load a specific spec: foundry load-spec {} <spec_name>",
123                project_name
124            ),
125            format!(
126                "You can create a new spec: foundry create-spec {} <feature_name>",
127                project_name
128            ),
129        ]
130    }
131}
132
133/// Generate workflow hints based on available specs
134fn generate_workflow_hints(specs_available: &[String]) -> Vec<String> {
135    let mut hints = vec![
136        "You can use the project summary for quick context in conversations".to_string(),
137        "The full vision provides comprehensive background and goals for your work".to_string(),
138        "Tech stack details guide your implementation decisions and technology choices".to_string(),
139        "You can skip list-projects calls when you know the project name - load_project is more efficient".to_string(),
140    ];
141
142    if specs_available.is_empty() {
143        hints.push(
144            "You can create specifications to track specific features as you identify them"
145                .to_string(),
146        );
147        hints.push(
148            "You can prompt the user about creating specifications to track specific features"
149                .to_string(),
150        );
151    } else {
152        hints.push(format!("Available specs: {}", specs_available.join(", ")));
153        hints.push(
154            "You can load individual specs to see detailed implementation plans and progress"
155                .to_string(),
156        );
157        hints.push("You can update existing specs with progress as work continues".to_string());
158    }
159
160    hints.push(
161        "You can use foundry get_foundry_help decision-points to understand tool selection"
162            .to_string(),
163    );
164
165    hints
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crate::core::filesystem;
172    use tempfile::TempDir;
173
174    fn create_test_args(project_name: &str) -> LoadProjectArgs {
175        LoadProjectArgs {
176            project_name: project_name.to_string(),
177        }
178    }
179
180    #[test]
181    fn test_validate_project_exists_missing_project() {
182        let result = validate_project_exists("non-existent-project-12345");
183
184        assert!(result.is_err());
185        let error_message = result.unwrap_err().to_string();
186        assert!(error_message.contains("not found"));
187        assert!(error_message.contains("list-projects"));
188    }
189
190    #[test]
191    fn test_generate_next_steps_no_specs() {
192        let project_name = "test-project";
193        let specs_available = Vec::<String>::new();
194        let steps = generate_next_steps(project_name, &specs_available);
195
196        assert_eq!(steps.len(), 3);
197        assert!(steps[0].contains("ready for specification creation"));
198        assert!(steps[1].contains("foundry create-spec"));
199        assert!(steps[1].contains(project_name));
200        assert!(steps[2].contains("comprehensive background"));
201    }
202
203    #[test]
204    fn test_generate_next_steps_with_specs() {
205        let project_name = "test-project";
206        let specs_available = vec![
207            "20240824_120000_feature1".to_string(),
208            "20240824_130000_feature2".to_string(),
209        ];
210        let steps = generate_next_steps(project_name, &specs_available);
211
212        assert_eq!(steps.len(), 3);
213        assert!(steps[0].contains("loaded with 2 specification"));
214        assert!(steps[1].contains("foundry load-spec"));
215        assert!(steps[1].contains(project_name));
216        assert!(steps[2].contains("foundry create-spec"));
217        assert!(steps[2].contains(project_name));
218    }
219
220    #[test]
221    fn test_generate_workflow_hints_no_specs() {
222        let specs_available = Vec::<String>::new();
223        let hints = generate_workflow_hints(&specs_available);
224
225        assert!(hints.len() >= 6);
226        assert!(hints.iter().any(|h| h.contains("project summary")));
227        assert!(hints.iter().any(|h| h.contains("vision provides")));
228        assert!(hints.iter().any(|h| h.contains("Tech stack")));
229        assert!(hints.iter().any(|h| h.contains("create specifications")));
230        // Should not contain spec-specific hints
231        assert!(!hints.iter().any(|h| h.contains("Available specs")));
232        assert!(!hints.iter().any(|h| h.contains("Load individual specs")));
233    }
234
235    #[test]
236    fn test_generate_workflow_hints_with_specs() {
237        let specs_available = vec![
238            "20240824_120000_feature1".to_string(),
239            "20240824_130000_feature2".to_string(),
240        ];
241        let hints = generate_workflow_hints(&specs_available);
242
243        assert!(hints.len() >= 6);
244        assert!(hints.iter().any(|h| h.contains("project summary")));
245        assert!(hints.iter().any(|h| h.contains("vision provides")));
246        assert!(hints.iter().any(|h| h.contains("Tech stack")));
247        assert!(hints.iter().any(|h| h.contains("Available specs")));
248        assert!(hints.iter().any(|h| h.contains("feature1")));
249        assert!(hints.iter().any(|h| h.contains("feature2")));
250        assert!(hints.iter().any(|h| h.contains("load individual specs")));
251        // Should not contain no-specs hints
252        assert!(!hints.iter().any(|h| h.contains("create specifications")));
253    }
254
255    #[test]
256    fn test_execute_with_missing_project() {
257        use crate::test_utils::TestEnvironment;
258        let env = TestEnvironment::new().unwrap();
259
260        env.with_env_async(|| async {
261            let args = create_test_args("non-existent-project");
262            let result = execute(args).await;
263
264            assert!(result.is_err());
265            let error_message = result.unwrap_err().to_string();
266            assert!(error_message.contains("not found"));
267        });
268    }
269
270    #[test]
271    fn test_load_project_context_missing_files() {
272        let temp_dir = TempDir::new().unwrap();
273        let project_name = "test-project-incomplete";
274        let project_path = temp_dir.path();
275
276        // Create minimal project structure without files
277        filesystem::create_dir_all(project_path).unwrap();
278        filesystem::create_dir_all(project_path.join("specs")).unwrap();
279
280        let context = load_project_context(project_name, project_path).unwrap();
281
282        assert_eq!(context.name, project_name);
283        // Should handle missing files gracefully with empty strings
284        assert!(context.vision.is_empty());
285        assert!(context.tech_stack.is_empty());
286        assert!(context.summary.is_empty());
287        assert!(context.specs_available.is_empty());
288        assert!(!context.created_at.is_empty()); // Should still have timestamp
289    }
290
291    #[test]
292    fn test_load_project_context_with_specs() {
293        let temp_dir = TempDir::new().unwrap();
294        let project_name = "test-project-with-specs";
295        let project_path = temp_dir.path();
296        let specs_dir = project_path.join("specs");
297
298        // Create project structure with specs
299        filesystem::create_dir_all(&specs_dir).unwrap();
300
301        // Create spec directories
302        let spec1_dir = specs_dir.join("20240824_120000_feature1");
303        let spec2_dir = specs_dir.join("20240824_130000_feature2");
304        filesystem::create_dir_all(&spec1_dir).unwrap();
305        filesystem::create_dir_all(&spec2_dir).unwrap();
306
307        let context = load_project_context(project_name, project_path).unwrap();
308
309        assert_eq!(context.name, project_name);
310        assert_eq!(context.specs_available.len(), 2);
311        assert!(
312            context
313                .specs_available
314                .contains(&"20240824_120000_feature1".to_string())
315        );
316        assert!(
317            context
318                .specs_available
319                .contains(&"20240824_130000_feature2".to_string())
320        );
321    }
322}